From d946e9027cd6008dbedde5e7f7c54d0e648ecc8c Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Mon, 11 Dec 2023 23:45:44 +1100 Subject: [PATCH] feat: add JavaScript extension (#11) ...using the new extension system. The JS extension allows template functions to be defined in a script in the template tree. This is enabled in the CLI by default. --- .github/workflows/ci.yml | 16 +- .golangci.yml | 2 + README.md | 5 +- cmd/scaffolder/go.mod | 14 +- cmd/scaffolder/go.sum | 67 ++++++- cmd/scaffolder/main.go | 10 +- extensions/javascript/go.mod | 20 +++ extensions/javascript/go.sum | 63 +++++++ extensions/javascript/javascript.go | 135 ++++++++++++++ extensions/javascript/javascript_test.go | 33 ++++ extensions/javascript/testdata/hello.txt | 1 + extensions/javascript/testdata/template.js | 9 + .../testdata/{{ \"backwards\" | reverse }}" | 0 go.sum | 2 - scaffolder.go | 166 ++++++++++-------- scaffolder_test.go | 52 ++---- scaffoldertest/testing.go | 59 +++++++ walkdir.go | 46 +++++ 18 files changed, 575 insertions(+), 125 deletions(-) create mode 100644 extensions/javascript/go.mod create mode 100644 extensions/javascript/go.sum create mode 100644 extensions/javascript/javascript.go create mode 100644 extensions/javascript/javascript_test.go create mode 100644 extensions/javascript/testdata/hello.txt create mode 100644 extensions/javascript/testdata/template.js create mode 100644 "extensions/javascript/testdata/{{ \"backwards\" | reverse }}" create mode 100644 scaffoldertest/testing.go create mode 100644 walkdir.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a06cdcc..b781a22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,23 @@ jobs: steps: - uses: actions/checkout@v4 - uses: cashapp/activate-hermit@v1 - - run: go test ./... + - run: | + find . -name go.mod | xargs -n1 dirname | while read dir; do ( + cd "$dir" + go mod tidy + go test -v ./... + ); done + git diff lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: cashapp/activate-hermit@v1 - - run: golangci-lint run + - run: | + find . -name go.mod | xargs -n1 dirname | while read dir; do ( + cd "$dir" + go mod tidy + golangci-lint run + ); done + git diff diff --git a/.golangci.yml b/.golangci.yml index 22e361c..6083f08 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -56,6 +56,8 @@ linters: - deadcode - golint - depguard + - tagalign + - gomoddirectives linters-settings: govet: diff --git a/README.md b/README.md index aab45ec..a24e781 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,13 @@ [![stability-experimental](https://img.shields.io/badge/stability-experimental-orange.svg)](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#experimental) [![Go Reference](https://pkg.go.dev/badge/github.com/TBD54566975/scaffolder.svg)](https://pkg.go.dev/github.com/TBD54566975/scaffolder) Scaffolder evaluates the scaffolding files at the given destination against -ctx: +ctx using the following rules: +- Templates are evaluated using the Go template engine. - Both path names and file contents are evaluated. - If a file name ends with `.tmpl`, the `.tmpl` suffix is removed. - If a file or directory name evalutes to the empty string it will be excluded. +- If a file named `template.js` exists in the root of the template directory, + all functions defined in this file will be available as Go template functions. [cookiecutter]: https://github.com/cookiecutter/cookiecutter diff --git a/cmd/scaffolder/go.mod b/cmd/scaffolder/go.mod index c510fea..74d9d40 100644 --- a/cmd/scaffolder/go.mod +++ b/cmd/scaffolder/go.mod @@ -4,12 +4,22 @@ go 1.21.5 replace github.com/TBD54566975/scaffolder => ../.. +replace github.com/TBD54566975/scaffolder/extensions/javascript => ../../extensions/javascript + require ( - github.com/TBD54566975/scaffolder v0.0.0-00010101000000-000000000000 + github.com/TBD54566975/scaffolder v0.5.1 github.com/iancoleman/strcase v0.3.0 ) require ( - github.com/alecthomas/errors v0.4.0 // indirect + github.com/dlclark/regexp2 v1.7.0 // indirect + github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + golang.org/x/text v0.3.8 // indirect +) + +require ( + github.com/TBD54566975/scaffolder/extensions/javascript v0.0.0-00010101000000-000000000000 github.com/alecthomas/kong v0.8.1 ) diff --git a/cmd/scaffolder/go.sum b/cmd/scaffolder/go.sum index 47a5a90..d39fc38 100644 --- a/cmd/scaffolder/go.sum +++ b/cmd/scaffolder/go.sum @@ -1,12 +1,67 @@ -github.com/alecthomas/assert/v2 v2.2.2 h1:Z/iVC0xZfWTaFNE6bA3z07T86hd45Xe2eLt6WVy2bbk= -github.com/alecthomas/assert/v2 v2.2.2/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= -github.com/alecthomas/errors v0.4.0 h1:zDIapqdw7gVx2BrQpw3Ll5YRGFuaiB0ywcjesqT0RIE= -github.com/alecthomas/errors v0.4.0/go.mod h1:0DQf6/xQp3f9rv+k72g2NmeTW2lC74kXA6b/8dN9BwY= +github.com/alecthomas/assert/v2 v2.4.1 h1:mwPZod/d35nlaCppr6sFP0rbCL05WH9fIo7lvsf47zo= +github.com/alecthomas/assert/v2 v2.4.1/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM= github.com/alecthomas/kong v0.8.1 h1:acZdn3m4lLRobeh3Zi2S2EpnXTd1mOL6U7xVml+vfkY= github.com/alecthomas/kong v0.8.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= -github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= -github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/repr v0.3.0 h1:NeYzUPfjjlqHY4KtzgKJiWd6sVq2eNUPTi34PiFGjY8= +github.com/alecthomas/repr v0.3.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= +github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= +github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= +github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw= +github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= +github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/cmd/scaffolder/main.go b/cmd/scaffolder/main.go index 80336b3..5cde151 100644 --- a/cmd/scaffolder/main.go +++ b/cmd/scaffolder/main.go @@ -11,11 +11,13 @@ import ( "github.com/iancoleman/strcase" "github.com/TBD54566975/scaffolder" + "github.com/TBD54566975/scaffolder/extensions/javascript" ) var cli struct { - JSON *os.File `help:"JSON file containing the context to use."` - Dir string `arg:"" help:"Directory to scaffold."` + JSON *os.File `help:"JSON file containing the context to use."` + Template string `arg:"" help:"Template directory." type:"existingdir"` + Dest string `arg:"" help:"Destination directory to scaffold." type:"existingdir"` } func main() { @@ -26,7 +28,7 @@ func main() { kctx.FatalIfErrorf(err, "failed to decode JSON") } } - err := scaffolder.Scaffold(cli.Dir, context, scaffolder.Functions(template.FuncMap{ + err := scaffolder.Scaffold(cli.Template, cli.Template, context, scaffolder.Functions(template.FuncMap{ "snake": strcase.ToSnake, "screamingSnake": strcase.ToScreamingSnake, "camel": strcase.ToCamel, @@ -39,6 +41,6 @@ func main() { "typename": func(v any) string { return reflect.Indirect(reflect.ValueOf(v)).Type().Name() }, - })) + }), scaffolder.Extend(javascript.Extension("template.js"))) kctx.FatalIfErrorf(err) } diff --git a/extensions/javascript/go.mod b/extensions/javascript/go.mod new file mode 100644 index 0000000..10f912e --- /dev/null +++ b/extensions/javascript/go.mod @@ -0,0 +1,20 @@ +module github.com/TBD54566975/scaffolder/extensions/javascript + +go 1.21.5 + +replace github.com/TBD54566975/scaffolder => ../.. + +require ( + github.com/TBD54566975/scaffolder v0.5.1 + github.com/alecthomas/assert/v2 v2.4.1 + github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d +) + +require ( + github.com/alecthomas/repr v0.3.0 // indirect + github.com/dlclark/regexp2 v1.7.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/extensions/javascript/go.sum b/extensions/javascript/go.sum new file mode 100644 index 0000000..daf56ce --- /dev/null +++ b/extensions/javascript/go.sum @@ -0,0 +1,63 @@ +github.com/alecthomas/assert/v2 v2.4.1 h1:mwPZod/d35nlaCppr6sFP0rbCL05WH9fIo7lvsf47zo= +github.com/alecthomas/assert/v2 v2.4.1/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM= +github.com/alecthomas/repr v0.3.0 h1:NeYzUPfjjlqHY4KtzgKJiWd6sVq2eNUPTi34PiFGjY8= +github.com/alecthomas/repr v0.3.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= +github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= +github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= +github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw= +github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= +github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/extensions/javascript/javascript.go b/extensions/javascript/javascript.go new file mode 100644 index 0000000..f4f97f0 --- /dev/null +++ b/extensions/javascript/javascript.go @@ -0,0 +1,135 @@ +package javascript + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "regexp" + + "github.com/dop251/goja" + + "github.com/TBD54566975/scaffolder" +) + +type config struct { + logger func(args ...any) +} + +func (o *config) makeLogFunc(prefix string) func(args ...any) { + return func(args ...any) { + values := make([]any, len(args)+1) + values[0] = prefix + copy(values[1:], args) + o.logger(values...) + } +} + +// Option is a function that modifies the behaviour of the interpreter. +type Option func(*config) + +// WithLogger sets the logger to use for console.log, console.debug, console.error and console.warn. +func WithLogger(logger func(args ...any)) Option { + return func(o *config) { o.logger = logger } +} + +// Extension is a scaffolder extension that allows the use of end-user-provided +// JavaScript code to write template functions. +// +// The extension will execute the JS file scriptPath in the source directory +// if present. If you wish to include a file named scriptPath in the generated +// output, you can name it scriptPath.tmpl. +// +// A global variable named context will be available in the JS VM. It will +// contain the scaffolder.Config.Context value. +// +// Existing template functions will also be available in the JS VM. +func Extension(scriptPath string, options ...Option) scaffolder.Extension { + conf := &config{ + logger: func(args ...any) { fmt.Fprintln(os.Stderr, args...) }, + } + for _, option := range options { + option(conf) + } + return scaffolder.ExtensionFunc(func(mutableConfig *scaffolder.Config) error { + // Exclude the script from the output. + mutableConfig.Exclude = append(mutableConfig.Exclude, "^"+regexp.QuoteMeta(scriptPath)+"$") + + vm := goja.New() + vm.SetFieldNameMapper(goja.UncapFieldNameMapper()) + for key, value := range mutableConfig.Funcs { + if err := vm.Set(key, value); err != nil { + return err + } + } + if err := initConsole(vm, conf); err != nil { + return err + } + if err := vm.Set("context", mutableConfig.Context); err != nil { + return err + } + scriptPath := filepath.Join(mutableConfig.Source(), scriptPath) + if script, err := os.ReadFile(scriptPath); err == nil { + if _, err := vm.RunScript(scriptPath, string(script)); err != nil { + return fmt.Errorf("failed to run %s: %w", scriptPath, err) + } + } + + global := vm.GlobalObject() + for _, key := range global.Keys() { + attr := global.Get(key) + value := attr.Export() + typ := reflect.TypeOf(value) + if typ == nil { + continue + } + if typ.Kind() != reflect.Func { + continue + } + + // Go functions are exported as is, JS functions are wrapped in a go function that calls them. + isJsFunc := typ.NumIn() == 1 && typ.In(0) == reflect.TypeOf(goja.FunctionCall{}) + + // Go function, expose it directly. + if !isJsFunc { + mutableConfig.Funcs[key] = value + continue + } + + // JS function, wrap it in func(...any) (any, error) + fn, ok := goja.AssertFunction(attr) + if !ok { + continue + } + mutableConfig.Funcs[key] = func(args ...any) (any, error) { + vmArgs := make([]goja.Value, len(args)) + for i, arg := range args { + vmArgs[i] = vm.ToValue(arg) + } + return fn(global, vmArgs...) + } + } + return nil + }) +} + +func initConsole(vm *goja.Runtime, conf *config) error { + console := vm.NewObject() + if err := console.Set("log", conf.makeLogFunc("log:")); err != nil { + return err + } + if err := console.Set("debug", conf.makeLogFunc("debug:")); err != nil { + return err + } + if err := console.Set("error", conf.makeLogFunc("error:")); err != nil { + return err + } + if err := console.Set("warn", conf.makeLogFunc("warn:")); err != nil { + return err + } + err := vm.Set("console", console) + if err != nil { + return err + } + return nil +} diff --git a/extensions/javascript/javascript_test.go b/extensions/javascript/javascript_test.go new file mode 100644 index 0000000..7eede56 --- /dev/null +++ b/extensions/javascript/javascript_test.go @@ -0,0 +1,33 @@ +package javascript + +import ( + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/TBD54566975/scaffolder" + "github.com/TBD54566975/scaffolder/scaffoldertest" +) + +type Context struct { + Name string +} + +func TestExtension(t *testing.T) { + dest := t.TempDir() + err := scaffolder.Scaffold("testdata", dest, Context{ + Name: "Alice", + }, + scaffolder.Extend(Extension("template.js")), + scaffolder.Functions(scaffolder.FuncMap{ + "goHello": func(c Context) string { + return "Hello " + c.Name + }, + }), + ) + assert.NoError(t, err) + scaffoldertest.AssertFilesEqual(t, dest, []scaffoldertest.File{ + {Name: "sdrawkcab", Mode: 0600}, + {Name: "hello.txt", Mode: 0600, Content: "Hello Alice"}, + }) +} diff --git a/extensions/javascript/testdata/hello.txt b/extensions/javascript/testdata/hello.txt new file mode 100644 index 0000000..1bcd5de --- /dev/null +++ b/extensions/javascript/testdata/hello.txt @@ -0,0 +1 @@ +{{ . | hello}} \ No newline at end of file diff --git a/extensions/javascript/testdata/template.js b/extensions/javascript/testdata/template.js new file mode 100644 index 0000000..2394b05 --- /dev/null +++ b/extensions/javascript/testdata/template.js @@ -0,0 +1,9 @@ +function reverse(s) { + return s.split("").reverse().join(""); +} + +// Verifies that types from the template are correctly propagated through JS and +// back to Go. +function hello(c) { + return goHello(c); +} diff --git "a/extensions/javascript/testdata/{{ \"backwards\" | reverse }}" "b/extensions/javascript/testdata/{{ \"backwards\" | reverse }}" new file mode 100644 index 0000000..e69de29 diff --git a/go.sum b/go.sum index 5088871..5a217a1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/alecthomas/assert/v2 v2.4.0 h1:/ZiZ0NnriAWPYYO+4eOjgzNELrFQLaHNr92mHSHFj9U= -github.com/alecthomas/assert/v2 v2.4.0/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM= github.com/alecthomas/assert/v2 v2.4.1 h1:mwPZod/d35nlaCppr6sFP0rbCL05WH9fIo7lvsf47zo= github.com/alecthomas/assert/v2 v2.4.1/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM= github.com/alecthomas/repr v0.3.0 h1:NeYzUPfjjlqHY4KtzgKJiWd6sVq2eNUPTi34PiFGjY8= diff --git a/scaffolder.go b/scaffolder.go index 3951e9f..96c7704 100644 --- a/scaffolder.go +++ b/scaffolder.go @@ -1,7 +1,8 @@ +// Package scaffolder is a general purpose file-system based scaffolding tool +// inspired by cookiecutter. package scaffolder import ( - "errors" "fmt" "io/fs" "os" @@ -12,17 +13,66 @@ import ( ) type scaffoldOptions struct { - after func(path string) error - funcs template.FuncMap - exclude []string + Config + plugins []Extension } +// Extension's allow the scaffolder to be extended. +type Extension interface { + Extend(mutableConfig *Config) error + AfterEach(path string) error +} + +// ExtensionFunc is a convenience type for creating an Extension.Extend from a function. +type ExtensionFunc func(mutableConfig *Config) error + +func (f ExtensionFunc) Extend(mutableConfig *Config) error { return f(mutableConfig) } +func (f ExtensionFunc) AfterEach(path string) error { return nil } + +// AfterExtensionFunc is a convenience type for creating an Extension.AfterEach from a function. +type AfterExtensionFunc func(path string) error + +func (f AfterExtensionFunc) Extend(mutableConfig *Config) error { return nil } +func (f AfterExtensionFunc) AfterEach(path string) error { return f(path) } + +// Option is a function that modifies the behaviour of the scaffolder. type Option func(*scaffoldOptions) -// Functions defines functions to use in scaffolding templates. -func Functions(funcs template.FuncMap) Option { +// FuncMap is a map of functions to use in scaffolding templates. +// +// The key is the function name and the value is a function taking a single +// argument and returning either `string` or `(string, error)`. +type FuncMap = template.FuncMap + +// Config for the scaffolding. +type Config struct { + Context any + Funcs FuncMap + Exclude []string + + source string + target string +} + +func (c *Config) Source() string { return c.source } +func (c *Config) Target() string { return c.target } + +// Functions adds functions to use in scaffolding templates. +func Functions(funcs FuncMap) Option { return func(o *scaffoldOptions) { - o.funcs = funcs + for k, v := range funcs { + o.Funcs[k] = v + } + } +} + +// Extend adds an Extension to the scaffolder. +// +// An extension can be used to add functions to the template context, to +// modify the template context, and so on. +func Extend(plugin Extension) Option { + return func(o *scaffoldOptions) { + o.plugins = append(o.plugins, plugin) } } @@ -31,29 +81,32 @@ func Functions(funcs template.FuncMap) Option { // Matching occurs before template evaluation and .tmpl suffix removal. func Exclude(paths ...string) Option { return func(so *scaffoldOptions) { - so.exclude = append(so.exclude, paths...) + so.Exclude = append(so.Exclude, paths...) } } -// After configures Scaffolder to call "after" for each file or directory after -// it is created. -func After(after func(path string) error) Option { - return func(so *scaffoldOptions) { so.after = after } -} - -// Scaffold evaluates the scaffolding files at the given source using ctx, then -// copies them into destination. +// AfterEach configures Scaffolder to call "after" for each file or directory +// created. // -// Both path names and file contents are evaluated. +// Useful for setting file permissions, etc. // -// If a file name ends with `.tmpl`, the `.tmpl` suffix is removed. -// -// Scaffold is inspired by [cookiecutter]. -// -// [cookiecutter]: https://github.com/cookiecutter/cookiecutter +// Each AfterEach function is called in order. +func AfterEach(after func(path string) error) Option { + return func(so *scaffoldOptions) { + so.plugins = append(so.plugins, AfterExtensionFunc(after)) + } +} + +// Scaffold evaluates the scaffolding files at the given source using ctx, while +// copying them into destination. func Scaffold(source, destination string, ctx any, options ...Option) error { opts := scaffoldOptions{ - after: func(path string) error { return nil }, + Config: Config{ + source: source, + target: destination, + Context: ctx, + Funcs: FuncMap{}, + }, } for _, option := range options { option(&opts) @@ -61,13 +114,19 @@ func Scaffold(source, destination string, ctx any, options ...Option) error { deferredSymlinks := map[string]string{} - err := walkDir(source, func(srcPath string, d fs.DirEntry) error { + for _, plugin := range opts.plugins { + if err := plugin.Extend(&opts.Config); err != nil { + return fmt.Errorf("failed to extend scaffolder: %w", err) + } + } + + err := WalkDir(source, func(srcPath string, d fs.DirEntry) error { path, err := filepath.Rel(source, srcPath) if err != nil { return fmt.Errorf("failed to get relative path: %w", err) } - for _, exclude := range opts.exclude { + for _, exclude := range opts.Exclude { if matched, err := regexp.MatchString(exclude, path); err != nil { return fmt.Errorf("invalid exclude pattern %q: %w", exclude, err) } else if matched { @@ -80,13 +139,13 @@ func Scaffold(source, destination string, ctx any, options ...Option) error { return fmt.Errorf("failed to get file info: %w", err) } - if lastComponent, err := evaluate(filepath.Base(path), ctx, opts.funcs); err != nil { + if lastComponent, err := evaluate(filepath.Base(path), ctx, opts.Funcs); err != nil { return fmt.Errorf("failed to evaluate path name: %w", err) } else if lastComponent == "" { - return errSkip + return ErrSkip } - dstPath, err := evaluate(path, ctx, opts.funcs) + dstPath, err := evaluate(path, ctx, opts.Funcs) if err != nil { return fmt.Errorf("failed to evaluate path name: %w", err) } @@ -101,7 +160,7 @@ func Scaffold(source, destination string, ctx any, options ...Option) error { return fmt.Errorf("failed to read symlink: %w", err) } - target, err = evaluate(target, ctx, opts.funcs) + target, err = evaluate(target, ctx, opts.Funcs) if err != nil { return fmt.Errorf("failed to evaluate symlink target: %w", err) } @@ -121,8 +180,10 @@ func Scaffold(source, destination string, ctx any, options ...Option) error { if err := os.MkdirAll(dstPath, 0700); err != nil { return fmt.Errorf("failed to create directory: %w", err) } - if err := opts.after(dstPath); err != nil { - return fmt.Errorf("after directory: %w", err) + for _, plugin := range opts.plugins { + if err := plugin.AfterEach(dstPath); err != nil { + return fmt.Errorf("failed to run after: %w", err) + } } case info.Mode().IsRegular(): @@ -131,7 +192,7 @@ func Scaffold(source, destination string, ctx any, options ...Option) error { if err != nil { return fmt.Errorf("failed to read file: %w", err) } - content, err := evaluate(string(template), ctx, opts.funcs) + content, err := evaluate(string(template), ctx, opts.Funcs) if err != nil { return fmt.Errorf("%s: failed to evaluate template: %w", srcPath, err) } @@ -139,8 +200,10 @@ func Scaffold(source, destination string, ctx any, options ...Option) error { if err != nil { return fmt.Errorf("failed to write file: %w", err) } - if err := opts.after(dstPath); err != nil { - return fmt.Errorf("after file: %w", err) + for _, plugin := range opts.plugins { + if err := plugin.AfterEach(dstPath); err != nil { + return fmt.Errorf("failed to run after: %w", err) + } } default: @@ -178,41 +241,6 @@ func applySymlinks(symlinks map[string]string, path string) error { return os.Symlink(target, path) } -// errSkip is returned by walkDir to skip a file or directory. -var errSkip = errors.New("skip directory") - -// Depth-first walk of dir executing fn after each entry. -func walkDir(dir string, fn func(path string, d fs.DirEntry) error) error { - dirInfo, err := os.Stat(dir) - if err != nil { - return err - } - if err = fn(dir, fs.FileInfoToDirEntry(dirInfo)); err != nil { - if errors.Is(err, errSkip) { - return nil - } - return err - } - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - for _, entry := range entries { - if entry.IsDir() { - err = walkDir(filepath.Join(dir, entry.Name()), fn) - if err != nil && !errors.Is(err, errSkip) { - return err - } - } else { - err = fn(filepath.Join(dir, entry.Name()), entry) - if err != nil && !errors.Is(err, errSkip) { - return err - } - } - } - return nil -} - func evaluate(tmpl string, ctx any, funcs template.FuncMap) (string, error) { t, err := template.New("scaffolding").Funcs(funcs).Parse(tmpl) if err != nil { diff --git a/scaffolder_test.go b/scaffolder_test.go index 9fecd9e..63a3259 100644 --- a/scaffolder_test.go +++ b/scaffolder_test.go @@ -1,55 +1,29 @@ -package scaffolder +package scaffolder_test import ( "os" "path/filepath" - "sort" "testing" "github.com/alecthomas/assert/v2" + + "github.com/TBD54566975/scaffolder" + "github.com/TBD54566975/scaffolder/scaffoldertest" ) func TestScaffolder(t *testing.T) { tmpDir := filepath.Join(t.TempDir(), "new") - err := Scaffold("testdata/template", tmpDir, map[string]any{ + err := scaffolder.Scaffold("testdata/template", tmpDir, map[string]any{ "Name": "test", "Include": true, - }, Exclude("excluded")) + }, scaffolder.Exclude("excluded")) assert.NoError(t, err) - type file struct { - name string - mode os.FileMode - content string - } - expect := []file{ - {"include", 0o600, "included"}, - {"included-dir/included", 0o600, "included"}, - {"intermediate", 0o700 | os.ModeSymlink, "Hello, test!\n"}, - {"regular-test", 0o600, "Hello, test!\n"}, - {"symlink-test", 0o700 | os.ModeSymlink, "Hello, test!\n"}, + expect := []scaffoldertest.File{ + {Name: "include", Mode: 0o600, Content: "included"}, + {Name: "included-dir/included", Mode: 0o600, Content: "included"}, + {Name: "intermediate", Mode: 0o700 | os.ModeSymlink, Content: "Hello, test!\n"}, + {Name: "regular-test", Mode: 0o600, Content: "Hello, test!\n"}, + {Name: "symlink-test", Mode: 0o700 | os.ModeSymlink, Content: "Hello, test!\n"}, } - actual := []file{} - err = walkDir(tmpDir, func(path string, d os.DirEntry) error { - info, err := d.Info() - if err != nil { - return err - } - rel, err := filepath.Rel(tmpDir, path) - if err != nil { - return err - } - var content []byte - if !d.IsDir() { - content, err = os.ReadFile(path) - if err != nil { - return err - } - actual = append(actual, file{name: rel, mode: info.Mode() & (os.ModeSymlink | 0o700), content: string(content)}) - } - - return nil - }) - assert.NoError(t, err) - sort.Slice(actual, func(i, j int) bool { return actual[i].name < actual[j].name }) - assert.Equal(t, expect, actual) + scaffoldertest.AssertFilesEqual(t, tmpDir, expect) } diff --git a/scaffoldertest/testing.go b/scaffoldertest/testing.go new file mode 100644 index 0000000..bc00bbf --- /dev/null +++ b/scaffoldertest/testing.go @@ -0,0 +1,59 @@ +package scaffoldertest + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/TBD54566975/scaffolder" +) + +type File struct { + Name string + Mode os.FileMode // Mode to expect - only the user permissions and symlink bits are used. + Content string +} + +func (f File) String() string { + return fmt.Sprintf("%-32s %s %q", f.Name, f.Mode, f.Content) +} + +func AssertFilesEqual(t *testing.T, dir string, expect []File) { + actual := []File{} + err := scaffolder.WalkDir(dir, func(path string, d os.DirEntry) error { + info, err := d.Info() + if err != nil { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + var content []byte + if !d.IsDir() { + content, err = os.ReadFile(path) + if err != nil { + return err + } + actual = append(actual, File{Name: rel, Mode: info.Mode() & (os.ModeSymlink | 0o700), Content: string(content)}) + } + + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(actual) != len(expect) { + t.Fatalf("expected %d files, got %d: %s", len(expect), len(actual), actual) + } + sort.Slice(expect, func(i, j int) bool { return expect[i].Name < expect[j].Name }) + sort.Slice(actual, func(i, j int) bool { return actual[i].Name < actual[j].Name }) + for i, file := range expect { + file.Mode &= os.ModeSymlink | 0o700 + if file != actual[i] { + t.Errorf("\nExpected: %s\n Actual: %s", file, actual[i]) + } + } +} diff --git a/walkdir.go b/walkdir.go new file mode 100644 index 0000000..9fed509 --- /dev/null +++ b/walkdir.go @@ -0,0 +1,46 @@ +package scaffolder + +import ( + "errors" + "io/fs" + "os" + "path/filepath" +) + +// ErrSkip can be returned by WalkDir callbacks to skip a file or directory. +var ErrSkip = errors.New("skip directory") + +// WalkDir performs a depth-first walk of dir, executing fn before each file or +// directory. +// +// If fn returns ErrSkip, the directory will be skipped. +func WalkDir(dir string, fn func(path string, d fs.DirEntry) error) error { + dirInfo, err := os.Stat(dir) + if err != nil { + return err + } + if err = fn(dir, fs.FileInfoToDirEntry(dirInfo)); err != nil { + if errors.Is(err, ErrSkip) { + return nil + } + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() { + err = WalkDir(filepath.Join(dir, entry.Name()), fn) + if err != nil && !errors.Is(err, ErrSkip) { + return err + } + } else { + err = fn(filepath.Join(dir, entry.Name()), entry) + if err != nil && !errors.Is(err, ErrSkip) { + return err + } + } + } + return nil +}