├── .github ├── FUNDING.yml └── workflows │ └── go.yml ├── images └── springerle.jpeg ├── main.go ├── .gitignore ├── example └── hello.txtar ├── go.mod ├── txtartmpl ├── txtartmpl_test.go ├── funcmaps.go └── txtartmpl.go ├── LICENSE ├── go.sum └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: carlmjohnson 2 | -------------------------------------------------------------------------------- /images/springerle.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/earthboundkid/springerle/HEAD/images/springerle.jpeg -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/carlmjohnson/exitcode" 7 | "github.com/carlmjohnson/springerle/txtartmpl" 8 | ) 9 | 10 | func main() { 11 | exitcode.Exit(txtartmpl.CLI(os.Args[1:])) 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | -------------------------------------------------------------------------------- /example/hello.txtar: -------------------------------------------------------------------------------- 1 | proj: Project name? My Project 2 | file: File name? {{ .proj | xstringstokebabcase }} 3 | class: Class name? {{ .proj | xstringstocamelcase }} 4 | skip: Skip this? y 5 | {{ if not .skip }}final: Anything else? n{{end}} 6 | -- {{ .proj }}/{{ .file }}.txt -- 7 | class {{ .class }} 8 | {{ printf "%#v" .skip }} 9 | {{ if not .skip }} 10 | -- {{ .proj }}/{{ .final }}.txt -- 11 | Not skipped! 12 | {{ end }} 13 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [ push, pull_request ] 4 | jobs: 5 | 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | 11 | - name: Set up Go 1.x 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ^1.16 15 | id: go 16 | 17 | - name: Check out code into the Go module directory 18 | uses: actions/checkout@v2 19 | 20 | - name: Get dependencies 21 | run: go mod download 22 | 23 | - name: Test 24 | run: go test -v ./... 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/carlmjohnson/springerle 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/Songmu/prompter v0.5.0 7 | github.com/carlmjohnson/be v0.22.4 8 | github.com/carlmjohnson/exitcode v0.20.2 9 | github.com/carlmjohnson/flagx v0.22.1 10 | github.com/carlmjohnson/versioninfo v0.22.1 11 | github.com/huandu/xstrings v1.3.2 12 | github.com/mitchellh/go-wordwrap v1.0.1 13 | golang.org/x/tools v0.1.10 14 | ) 15 | 16 | require ( 17 | github.com/mattn/go-isatty v0.0.14 // indirect 18 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect 19 | golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /txtartmpl/txtartmpl_test.go: -------------------------------------------------------------------------------- 1 | package txtartmpl_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/carlmjohnson/be" 8 | "github.com/carlmjohnson/exitcode" 9 | "github.com/carlmjohnson/springerle/txtartmpl" 10 | ) 11 | 12 | func TestCLI(t *testing.T) { 13 | cases := map[string]struct { 14 | in string 15 | code int 16 | }{ 17 | "help": {"-h", 0}, 18 | "longhelp": {"--help", 0}, 19 | "bad-context": {`-context }`, 1}, 20 | "blank-context": {`-context {}`, 0}, 21 | } 22 | for name, tc := range cases { 23 | t.Run(name, func(t *testing.T) { 24 | err := txtartmpl.CLI(strings.Fields(tc.in)) 25 | be.Equal(t, tc.code, exitcode.Get(err)) 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Carl Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Songmu/prompter v0.5.0 h1:uf60xlFItY5nW+rlLJ2XIUfaUReo4gUEeftuUeHpio8= 2 | github.com/Songmu/prompter v0.5.0/go.mod h1:S4Eg25l60kPlnfB2ttFVpvBKYw7RKJexzB3gzpAansY= 3 | github.com/carlmjohnson/be v0.22.4 h1:CEYQrjQu8ABgEryNXibdk9gvJb7I0yg3iTAK7L4c2bk= 4 | github.com/carlmjohnson/be v0.22.4/go.mod h1:KAgPUh0HpzWYZZI+IABdo80wTgY43YhbdsiLYAaSI/Q= 5 | github.com/carlmjohnson/exitcode v0.20.2 h1:vE6rmkCGNA4kO4m1qwWIa77PKlUBVg46cNjs22eAOXE= 6 | github.com/carlmjohnson/exitcode v0.20.2/go.mod h1:MZ6ThCDx517DQcrpYnnns1pLh8onjFl+B/AsrOrdmpc= 7 | github.com/carlmjohnson/flagx v0.22.1 h1:vg8y7Kl4rUJXwhlcVmvJAm1YEtyxXLLtEKQLHiGykzc= 8 | github.com/carlmjohnson/flagx v0.22.1/go.mod h1:obobISvBnxgEXPLBITVXhRUOlSlzza1SGt34M64CPJc= 9 | github.com/carlmjohnson/versioninfo v0.22.1 h1:NVwTmCUpSoxBxy8+z10CbyBazeRZ4R2n6QgrNi3Wd6M= 10 | github.com/carlmjohnson/versioninfo v0.22.1/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 11 | github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= 12 | github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 13 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 14 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 15 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 16 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 17 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 18 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 19 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 20 | golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 22 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc= 23 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 25 | golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8= 26 | golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 27 | golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= 28 | golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Springerle [![GoDoc](https://godoc.org/github.com/carlmjohnson/springerle?status.svg)](https://godoc.org/github.com/carlmjohnson/springerle) [![Go Report Card](https://goreportcard.com/badge/github.com/carlmjohnson/springerle)](https://goreportcard.com/report/github.com/carlmjohnson/springerle) 2 | 3 | Springerle are a kind of [German prestamped cookie](https://drawinglinks.substack.com/p/springerle-cookies). Springerle is a command line tool for creating simple prestamped project files with the txtar format and Go templates. Inspired by [Cookiecutter](https://cookiecutter.readthedocs.io/) and [JTree Stamp](https://github.com/publicdomaincompany/jtree/tree/master/langs/stamp). 4 | 5 | ![](images/springerle.jpeg) 6 | 7 | ## Installation 8 | 9 | First install [Go](http://golang.org). 10 | 11 | If you just want to install the binary to your current directory and don't care about the source code, run 12 | 13 | ```bash 14 | GOBIN=$(pwd) go install github.com/carlmjohnson/springerle@latest 15 | ``` 16 | 17 | ## Screenshots 18 | 19 | ``` 20 | $ springerle -h 21 | springerle v0.21.4 - create simple projects with the txtar format and Go templates. 22 | 23 | Usage: 24 | 25 | springerle [options] 26 | 27 | Project files are Go templates processed as txtar files. The preamble to the 28 | txtar file is used as a series of prompts for creating the template context. 29 | Each line should be formated as "key: User prompt question? default value" with 30 | colon and question mark used as delimiters. Lines beginning with # or without a 31 | colon are ignored. If the default value is "y" or "n", the prompt will be 32 | treated as a boolean. Prompt lines may use templates directives, e.g. to 33 | transform a prior prompt value into a default or skip irrelevant prompts, but 34 | premable template directives must be valid at the line level. That is, there 35 | can be no multiline blocks in the preamble. 36 | 37 | To templatize files that contain other templates, set -left-delim and 38 | -right-delim options to something not used in the template. 39 | 40 | In addition to the default Go template functions, templates can use the 41 | functions listed below. In order to avoid name clashes, the added function 42 | names follow a specific pattern: they combine their original package and 43 | function names using no punctuation and only lowercase letters. E.g., 44 | strings.LastIndexByte becomes stringslastindexbyte. 45 | 46 | From package strings: 47 | 48 | stringscompare stringscontains stringscontainsany stringscontainsrune 49 | stringscount stringsequalfold stringsfields stringsfieldsfunc stringshasprefix 50 | stringshassuffix stringsindex stringsindexany stringsindexbyte 51 | stringsindexfunc stringsindexrune stringsjoin stringslastindex 52 | stringslastindexany stringslastindexbyte stringslastindexfunc stringsmap 53 | stringsrepeat stringsreplace stringsreplaceall stringssplit stringssplitafter 54 | stringssplitaftern stringssplitn stringstitle stringstolower 55 | stringstolowerspecial stringstotitle stringstotitlespecial stringstoupper 56 | stringstoupperspecial stringstovalidutf8 stringstrim stringstrimfunc 57 | stringstrimleft stringstrimleftfunc stringstrimprefix stringstrimright 58 | stringstrimrightfunc stringstrimspace stringstrimsuffix 59 | 60 | From package path/filepath: 61 | 62 | filepathabs filepathbase filepathclean filepathdir filepathext 63 | filepathfromslash filepathisabs filepathjoin filepathmatch filepathrel 64 | filepathsplit filepathsplitlist filepathtoslash filepathvolumename 65 | 66 | From package time: 67 | 68 | timedate timenow timeparse timeparseduration 69 | 70 | From github.com/huandu/xstrings: 71 | 72 | xstringscenter xstringscount xstringsdelete xstringsexpandtabs 73 | xstringsfirstrunetolower xstringsfirstrunetoupper xstringsinsert 74 | xstringslastpartition xstringsleftjustify xstringslen xstringspartition 75 | xstringsreverse xstringsrightjustify xstringsrunewidth xstringsscrub 76 | xstringsshuffle xstringsshufflesource xstringsslice xstringssqueeze 77 | xstringssuccessor xstringsswapcase xstringstocamelcase xstringstokebabcase 78 | xstringstosnakecase xstringstranslate xstringswidth xstringswordcount 79 | xstringswordsplit 80 | 81 | From github.com/mitchellh/go-wordwrap 82 | 83 | wordwrapwrapstring wrapstring 84 | 85 | The 'wordwrap' package is a slight exception to the rules for added function 86 | names. Both 'wordwrapwrapstring' and 'wrapstring' are aliases to the same 87 | function. 88 | 89 | Options: 90 | -context JSON 91 | JSON object to use as template context 92 | -dest path 93 | destination path (default ".") 94 | -dry-run 95 | dry run output only (output txtar to stdout) 96 | -dump-context path 97 | path to load/save context produced by user input 98 | -left-delim delimiter 99 | left delimiter to use when parsing template (default "{{") 100 | -right-delim delimiter 101 | right delimiter to use when parsing template (default "}}") 102 | -verbose 103 | log debug output (default silent) 104 | ``` 105 | -------------------------------------------------------------------------------- /txtartmpl/funcmaps.go: -------------------------------------------------------------------------------- 1 | package txtartmpl 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | "time" 7 | 8 | "github.com/huandu/xstrings" 9 | "github.com/mitchellh/go-wordwrap" 10 | ) 11 | 12 | func xStringFuncMap() map[string]any { 13 | return map[string]any{ 14 | "xstringscenter": xstrings.Center, 15 | "xstringscount": xstrings.Count, 16 | "xstringsdelete": xstrings.Delete, 17 | "xstringsexpandtabs": xstrings.ExpandTabs, 18 | "xstringsfirstrunetolower": xstrings.FirstRuneToLower, 19 | "xstringsfirstrunetoupper": xstrings.FirstRuneToUpper, 20 | "xstringsinsert": xstrings.Insert, 21 | "xstringslastpartition": func(str, sep string) [3]string { 22 | f, p, l := xstrings.LastPartition(str, sep) 23 | return [...]string{f, p, l} 24 | }, 25 | "xstringsleftjustify": xstrings.LeftJustify, 26 | "xstringslen": xstrings.Len, 27 | "xstringspartition": func(str, sep string) [3]string { 28 | f, p, l := xstrings.Partition(str, sep) 29 | return [...]string{f, p, l} 30 | }, 31 | "xstringsreverse": xstrings.Reverse, 32 | "xstringsrightjustify": xstrings.RightJustify, 33 | "xstringsrunewidth": xstrings.RuneWidth, 34 | "xstringsscrub": xstrings.Scrub, 35 | "xstringsshuffle": xstrings.Shuffle, 36 | "xstringsshufflesource": xstrings.ShuffleSource, 37 | "xstringsslice": xstrings.Slice, 38 | "xstringssqueeze": xstrings.Squeeze, 39 | "xstringssuccessor": xstrings.Successor, 40 | "xstringsswapcase": xstrings.SwapCase, 41 | "xstringstocamelcase": xstrings.ToCamelCase, 42 | "xstringstokebabcase": xstrings.ToKebabCase, 43 | "xstringstosnakecase": xstrings.ToSnakeCase, 44 | "xstringstranslate": xstrings.Translate, 45 | "xstringswidth": xstrings.Width, 46 | "xstringswordcount": xstrings.WordCount, 47 | "xstringswordsplit": xstrings.WordSplit, 48 | } 49 | } 50 | 51 | func stringFuncMap() map[string]any { 52 | return map[string]any{ 53 | "stringscompare": strings.Compare, 54 | "stringscontains": strings.Contains, 55 | "stringscontainsany": strings.ContainsAny, 56 | "stringscontainsrune": strings.ContainsRune, 57 | "stringscount": strings.Count, 58 | "stringsequalfold": strings.EqualFold, 59 | "stringsfields": strings.Fields, 60 | "stringsfieldsfunc": strings.FieldsFunc, 61 | "stringshasprefix": strings.HasPrefix, 62 | "stringshassuffix": strings.HasSuffix, 63 | "stringsindex": strings.Index, 64 | "stringsindexany": strings.IndexAny, 65 | "stringsindexbyte": strings.IndexByte, 66 | "stringsindexfunc": strings.IndexFunc, 67 | "stringsindexrune": strings.IndexRune, 68 | "stringsjoin": strings.Join, 69 | "stringslastindex": strings.LastIndex, 70 | "stringslastindexany": strings.LastIndexAny, 71 | "stringslastindexbyte": strings.LastIndexByte, 72 | "stringslastindexfunc": strings.LastIndexFunc, 73 | "stringsmap": strings.Map, 74 | "stringsrepeat": strings.Repeat, 75 | "stringsreplace": strings.Replace, 76 | "stringsreplaceall": strings.ReplaceAll, 77 | "stringssplit": strings.Split, 78 | "stringssplitafter": strings.SplitAfter, 79 | "stringssplitaftern": strings.SplitAfterN, 80 | "stringssplitn": strings.SplitN, 81 | "stringstitle": strings.Title, 82 | "stringstolower": strings.ToLower, 83 | "stringstolowerspecial": strings.ToLowerSpecial, 84 | "stringstotitle": strings.ToTitle, 85 | "stringstotitlespecial": strings.ToTitleSpecial, 86 | "stringstoupper": strings.ToUpper, 87 | "stringstoupperspecial": strings.ToUpperSpecial, 88 | "stringstovalidutf8": strings.ToValidUTF8, 89 | "stringstrim": strings.Trim, 90 | "stringstrimfunc": strings.TrimFunc, 91 | "stringstrimleft": strings.TrimLeft, 92 | "stringstrimleftfunc": strings.TrimLeftFunc, 93 | "stringstrimprefix": strings.TrimPrefix, 94 | "stringstrimright": strings.TrimRight, 95 | "stringstrimrightfunc": strings.TrimRightFunc, 96 | "stringstrimspace": strings.TrimSpace, 97 | "stringstrimsuffix": strings.TrimSuffix, 98 | } 99 | } 100 | 101 | func filepathFuncMap() map[string]any { 102 | return map[string]any{ 103 | "filepathabs": filepath.Abs, 104 | "filepathbase": filepath.Base, 105 | "filepathclean": filepath.Clean, 106 | "filepathdir": filepath.Dir, 107 | "filepathext": filepath.Ext, 108 | "filepathjoin": filepath.Join, 109 | "filepathfromslash": filepath.FromSlash, 110 | "filepathisabs": filepath.IsAbs, 111 | "filepathmatch": filepath.Match, 112 | "filepathrel": filepath.Rel, 113 | "filepathsplit": func(path string) [2]string { 114 | head, tail := filepath.Split(path) 115 | return [...]string{head, tail} 116 | }, 117 | "filepathsplitlist": filepath.SplitList, 118 | "filepathtoslash": filepath.ToSlash, 119 | "filepathvolumename": filepath.VolumeName, 120 | } 121 | } 122 | 123 | func timeFuncMap() map[string]any { 124 | return map[string]any{ 125 | "timedate": time.Date, 126 | "timenow": time.Now, 127 | "timeparse": time.Parse, 128 | "timeparseduration": time.ParseDuration, 129 | } 130 | } 131 | 132 | func wordWrapFuncMap() map[string]any { 133 | return map[string]any{ 134 | "wordwrapwrapstring": wordwrap.WrapString, 135 | "wrapstring": wordwrap.WrapString, 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /txtartmpl/txtartmpl.go: -------------------------------------------------------------------------------- 1 | package txtartmpl 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "os" 12 | "path/filepath" 13 | "sort" 14 | "strings" 15 | "text/template" 16 | 17 | "github.com/Songmu/prompter" 18 | "github.com/carlmjohnson/flagx" 19 | "github.com/carlmjohnson/flagx/lazyio" 20 | "github.com/carlmjohnson/versioninfo" 21 | "github.com/mitchellh/go-wordwrap" 22 | "golang.org/x/tools/txtar" 23 | ) 24 | 25 | const AppName = "springerle" 26 | 27 | // CLI runs the springerle command line application. The application name (os.Args[0]) 28 | // should not be passed to CLI. The returned error contains the CLI's the exit code. 29 | func CLI(args []string) error { 30 | var app appEnv 31 | err := app.ParseArgs(args) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if err = app.Exec(); err != nil { 37 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 38 | } 39 | return err 40 | } 41 | 42 | func (app *appEnv) ParseArgs(args []string) error { 43 | fl := flag.NewFlagSet(AppName, flag.ContinueOnError) 44 | app.Logger = log.New(io.Discard, AppName+" ", log.LstdFlags) 45 | verbose := fl.Bool("verbose", false, "log debug output") 46 | fl.StringVar(&app.dstPath, "dest", ".", "destination `path`") 47 | fl.BoolVar(&app.dryRun, "dry-run", false, "dry run output only (output txtar to stdout)") 48 | fl.Func("context", "`JSON` object to use as template context", app.setTmplCtx) 49 | fl.StringVar(&app.dumpCtx, "dump-context", "", "`path` to load/save context produced by user input") 50 | fl.StringVar(&app.leftD, "left-delim", "{{", "left `delimiter` to use when parsing template") 51 | fl.StringVar(&app.rightD, "right-delim", "}}", "right `delimiter` to use when parsing template") 52 | 53 | app.setusage(fl) 54 | if err := fl.Parse(args); err != nil { 55 | return err 56 | } 57 | if err := flagx.ParseEnv(fl, AppName); err != nil { 58 | return err 59 | } 60 | if err := flagx.MustHaveArgs(fl, 0, 1); err != nil { 61 | return err 62 | } 63 | if *verbose { 64 | app.Logger.SetOutput(os.Stderr) 65 | } 66 | src := lazyio.FileOrURL(lazyio.StdIO, nil) 67 | app.src = src 68 | if fl.NArg() > 0 { 69 | return src.Set(fl.Arg(0)) 70 | } 71 | return nil 72 | } 73 | 74 | type appEnv struct { 75 | dstPath string 76 | dryRun bool 77 | leftD, rightD string 78 | src io.ReadCloser 79 | tmplCtx map[string]any 80 | dumpCtx string 81 | *log.Logger 82 | } 83 | 84 | func (app *appEnv) setusage(fl *flag.FlagSet) { 85 | fl.Usage = func() { 86 | s := fmt.Sprintf( 87 | `springerle - create simple projects with the txtar format and Go templates. 88 | 89 | Version %s %s 90 | 91 | Usage: 92 | 93 | springerle [options] 94 | 95 | Project files are Go templates processed as txtar files. The preamble to the txtar file is used as a series of prompts for creating the template context. Each line should be formated as "key: User prompt question? default value" with colon and question mark used as delimiters. Lines beginning with # or without a colon are ignored. If the default value is "y" or "n", the prompt will be treated as a boolean. Prompt lines may use templates directives, e.g. to transform a prior prompt value into a default or skip irrelevant prompts, but premable template directives must be valid at the line level. That is, there can be no multiline blocks in the preamble. 96 | 97 | To templatize files that contain other templates, set -left-delim and -right-delim options to something not used in the template. 98 | 99 | In addition to the default Go template functions, templates can use the functions listed below. In order to avoid name clashes, the added function names follow a specific pattern: they combine their original package and function names using no punctuation and only lowercase letters. E.g., strings.LastIndexByte becomes stringslastindexbyte. 100 | 101 | From package strings: 102 | 103 | %s 104 | 105 | From package path/filepath: 106 | 107 | %s 108 | 109 | From package time: 110 | 111 | %s 112 | 113 | From github.com/huandu/xstrings: 114 | 115 | %s 116 | 117 | From github.com/mitchellh/go-wordwrap 118 | 119 | %s 120 | 121 | The 'wordwrap' package is a slight exception to the rules for added function names. Both 'wordwrapwrapstring' and 'wrapstring' are aliases to the same function. 122 | 123 | Options: 124 | `, 125 | versioninfo.Version, 126 | versioninfo.Revision, 127 | sortFuncMapNames(stringFuncMap()), 128 | sortFuncMapNames(filepathFuncMap()), 129 | sortFuncMapNames(timeFuncMap()), 130 | sortFuncMapNames(xStringFuncMap()), 131 | sortFuncMapNames(wordWrapFuncMap()), 132 | ) 133 | fmt.Fprint(fl.Output(), wordwrap.WrapString(s, 79)) 134 | fl.PrintDefaults() 135 | fmt.Fprintln(fl.Output()) 136 | } 137 | } 138 | 139 | func (app *appEnv) setTmplCtx(s string) error { 140 | app.tmplCtx = make(map[string]any) 141 | return json.Unmarshal([]byte(s), &app.tmplCtx) 142 | } 143 | 144 | func sortFuncMapNames(m template.FuncMap) string { 145 | ss := make([]string, 0, len(m)) 146 | for k := range m { 147 | ss = append(ss, k) 148 | } 149 | sort.Strings(ss) 150 | return strings.Join(ss, " ") 151 | } 152 | 153 | func (app *appEnv) Exec() (err error) { 154 | var buf bytes.Buffer 155 | 156 | if _, err = io.Copy(&buf, app.src); err != nil { 157 | return err 158 | } 159 | // check template validity 160 | t := template.New(""). 161 | Option("missingkey=error"). 162 | Delims(app.leftD, app.rightD). 163 | Funcs(stringFuncMap()). 164 | Funcs(filepathFuncMap()). 165 | Funcs(timeFuncMap()). 166 | Funcs(xStringFuncMap()). 167 | Funcs(wordWrapFuncMap()) 168 | if t, err = t.Parse(buf.String()); err != nil { 169 | return fmt.Errorf("could not parse input as template: %w", err) 170 | } 171 | // read preamble by line, make up a Question context map 172 | ar := txtar.Parse(buf.Bytes()) 173 | tctx, err := app.TemplateContextFrom(ar.Comment) 174 | if err != nil { 175 | return err 176 | } 177 | // feed src through template.Template 178 | buf.Reset() 179 | if err = t.Execute(&buf, tctx); err != nil { 180 | return err 181 | } 182 | 183 | // make all the files 184 | ar = txtar.Parse(buf.Bytes()) 185 | if app.dryRun { 186 | app.Printf("dry run for %q", app.dstPath) 187 | ar.Comment = nil 188 | s := string(txtar.Format(ar)) 189 | fmt.Print(s) 190 | if !strings.HasSuffix(s, "\n") { 191 | fmt.Println() 192 | } 193 | return nil 194 | } 195 | 196 | for _, f := range ar.Files { 197 | name := filepath.Clean(f.Name) 198 | if strings.HasPrefix(name, "../") { 199 | return fmt.Errorf("won't write unsafe file name to disk: %q", f.Name) 200 | } 201 | name = filepath.FromSlash(filepath.Join(app.dstPath, name)) 202 | if err := os.MkdirAll(filepath.Dir(name), 0o777); err != nil { 203 | return err 204 | } 205 | app.Printf("writing %q", name) 206 | var perm os.FileMode = 0o666 207 | if filepath.Ext(name) == ".sh" { 208 | perm = 0o777 209 | } 210 | if err := os.WriteFile(name, f.Data, perm); err != nil { 211 | return err 212 | } 213 | } 214 | 215 | return err 216 | } 217 | 218 | func (app *appEnv) dumpContext(tctx map[string]any) { 219 | if app.dumpCtx == "" { 220 | return 221 | } 222 | b, err := json.Marshal(tctx) 223 | if err != nil { 224 | panic(err) 225 | } 226 | if err = os.WriteFile(app.dumpCtx, b, 0644); err != nil { 227 | fmt.Fprintf(os.Stderr, "problem dumping context file: %v\n", err) 228 | } 229 | } 230 | 231 | func (app *appEnv) TemplateContextFrom(b []byte) (map[string]any, error) { 232 | if app.tmplCtx != nil { 233 | return app.tmplCtx, nil 234 | } 235 | m := make(map[string]any) 236 | if app.dumpCtx != "" { 237 | if b, err := os.ReadFile(app.dumpCtx); err == nil { 238 | _ = json.Unmarshal(b, &m) 239 | } 240 | } 241 | t := template.New(""). 242 | Delims(app.leftD, app.rightD). 243 | Funcs(stringFuncMap()). 244 | Funcs(filepathFuncMap()). 245 | Funcs(timeFuncMap()). 246 | Funcs(xStringFuncMap()). 247 | Funcs(wordWrapFuncMap()) 248 | s := bufio.NewScanner(bytes.NewReader(b)) 249 | for s.Scan() { 250 | if err := app.processLine(t, s.Text(), m); err != nil { 251 | return nil, err 252 | } 253 | app.dumpContext(m) 254 | } 255 | 256 | return m, s.Err() 257 | } 258 | 259 | func (app *appEnv) processLine(t *template.Template, line string, m map[string]any) error { 260 | var k, q, v string 261 | if strings.Contains(line, app.leftD) { 262 | var buf strings.Builder 263 | t, err := t.Parse(line) 264 | if err != nil { 265 | return fmt.Errorf("could not parse preliminary prompt as template: %w", err) 266 | } 267 | t.Execute(&buf, m) 268 | line = buf.String() 269 | } 270 | line = strings.TrimSpace(line) 271 | if strings.HasPrefix(line, "#") { 272 | return nil 273 | } 274 | k, v, ok := strings.Cut(line, ":") 275 | if !ok { 276 | return nil 277 | } 278 | q = k 279 | if prefix, suffix, ok := strings.Cut(v, "?"); ok { 280 | q, v = prefix, suffix 281 | } 282 | k = strings.TrimSpace(k) 283 | v = strings.TrimSpace(v) 284 | q = strings.TrimSpace(q) 285 | 286 | if def, ok := m[k]; ok { 287 | if defb, ok := def.(bool); ok { 288 | m[k] = prompter.YN(q, defb) 289 | return nil 290 | } 291 | if defs, ok := def.(string); ok { 292 | m[k] = prompter.Prompt(q, defs) 293 | return nil 294 | } 295 | } 296 | 297 | if l := strings.ToLower(v); l == "y" || l == "n" { 298 | m[k] = prompter.YN(q, l == "y") 299 | return nil 300 | } 301 | 302 | m[k] = prompter.Prompt(q, v) 303 | return nil 304 | } 305 | --------------------------------------------------------------------------------