github.com/hairyhenderson/gomplate/v3@v3.11.7/template.go (about) 1 package gomplate 2 3 import ( 4 "context" 5 "fmt" 6 "io" 7 "io/fs" 8 "os" 9 "path" 10 "path/filepath" 11 "strings" 12 "text/template" 13 14 "github.com/hairyhenderson/go-fsimpl" 15 "github.com/hairyhenderson/gomplate/v3/internal/config" 16 "github.com/hairyhenderson/gomplate/v3/internal/iohelpers" 17 "github.com/hairyhenderson/gomplate/v3/tmpl" 18 19 "github.com/spf13/afero" 20 "github.com/zealic/xignore" 21 ) 22 23 // ignorefile name, like .gitignore 24 const gomplateignore = ".gomplateignore" 25 26 // for overriding in tests 27 var aferoFS = afero.NewOsFs() 28 29 func addTmplFuncs(f template.FuncMap, root *template.Template, tctx interface{}, path string) { 30 t := tmpl.New(root, tctx, path) 31 tns := func() *tmpl.Template { return t } 32 f["tmpl"] = tns 33 f["tpl"] = t.Inline 34 } 35 36 // copyFuncMap - copies the template.FuncMap into a new map so we can modify it 37 // without affecting the original 38 func copyFuncMap(funcMap template.FuncMap) template.FuncMap { 39 if funcMap == nil { 40 return nil 41 } 42 43 newFuncMap := make(template.FuncMap, len(funcMap)) 44 for k, v := range funcMap { 45 newFuncMap[k] = v 46 } 47 return newFuncMap 48 } 49 50 var fsProviderCtxKey = struct{}{} 51 52 // ContextWithFSProvider returns a context with the given FSProvider. Should 53 // only be used in tests. 54 func ContextWithFSProvider(ctx context.Context, fsp fsimpl.FSProvider) context.Context { 55 return context.WithValue(ctx, fsProviderCtxKey, fsp) 56 } 57 58 // FSProviderFromContext returns the FSProvider from the context, if any 59 func FSProviderFromContext(ctx context.Context) fsimpl.FSProvider { 60 if fsp, ok := ctx.Value(fsProviderCtxKey).(fsimpl.FSProvider); ok { 61 return fsp 62 } 63 64 return nil 65 } 66 67 // parseTemplate - parses text as a Go template with the given name and options 68 func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMap, tmplctx interface{}, nested config.Templates, leftDelim, rightDelim string) (tmpl *template.Template, err error) { 69 tmpl = template.New(name) 70 tmpl.Option("missingkey=error") 71 72 funcMap := copyFuncMap(funcs) 73 74 // the "tmpl" funcs get added here because they need access to the root template and context 75 addTmplFuncs(funcMap, tmpl, tmplctx, name) 76 tmpl.Funcs(funcMap) 77 tmpl.Delims(leftDelim, rightDelim) 78 _, err = tmpl.Parse(text) 79 if err != nil { 80 return nil, err 81 } 82 83 err = parseNestedTemplates(ctx, nested, tmpl) 84 if err != nil { 85 return nil, fmt.Errorf("parse nested templates: %w", err) 86 } 87 88 return tmpl, nil 89 } 90 91 func parseNestedTemplates(ctx context.Context, nested config.Templates, tmpl *template.Template) error { 92 fsp := FSProviderFromContext(ctx) 93 94 for alias, n := range nested { 95 u := *n.URL 96 97 fname := path.Base(u.Path) 98 if strings.HasSuffix(u.Path, "/") { 99 fname = "." 100 } 101 102 u.Path = path.Dir(u.Path) 103 104 fsys, err := fsp.New(&u) 105 if err != nil { 106 return fmt.Errorf("filesystem provider for %q unavailable: %w", &u, err) 107 } 108 109 // inject context & header in case they're useful... 110 fsys = fsimpl.WithContextFS(ctx, fsys) 111 fsys = fsimpl.WithHeaderFS(n.Header, fsys) 112 113 // valid fs.FS paths have no trailing slash 114 fname = strings.TrimRight(fname, "/") 115 116 // first determine if the template path is a directory, in which case we 117 // need to load all the files in the directory (but not recursively) 118 fi, err := fs.Stat(fsys, fname) 119 if err != nil { 120 return fmt.Errorf("stat %q: %w", fname, err) 121 } 122 123 if fi.IsDir() { 124 err = parseNestedTemplateDir(ctx, fsys, alias, fname, tmpl) 125 } else { 126 err = parseNestedTemplate(ctx, fsys, alias, fname, tmpl) 127 } 128 129 if err != nil { 130 return err 131 } 132 } 133 134 return nil 135 } 136 137 func parseNestedTemplateDir(ctx context.Context, fsys fs.FS, alias, fname string, tmpl *template.Template) error { 138 files, err := fs.ReadDir(fsys, fname) 139 if err != nil { 140 return fmt.Errorf("readDir %q: %w", fname, err) 141 } 142 143 for _, f := range files { 144 if !f.IsDir() { 145 err = parseNestedTemplate(ctx, fsys, 146 path.Join(alias, f.Name()), 147 path.Join(fname, f.Name()), 148 tmpl, 149 ) 150 if err != nil { 151 return err 152 } 153 } 154 } 155 156 return nil 157 } 158 159 func parseNestedTemplate(_ context.Context, fsys fs.FS, alias, fname string, tmpl *template.Template) error { 160 b, err := fs.ReadFile(fsys, fname) 161 if err != nil { 162 return fmt.Errorf("readFile %q: %w", fname, err) 163 } 164 165 _, err = tmpl.New(alias).Parse(string(b)) 166 if err != nil { 167 return fmt.Errorf("parse nested template %q: %w", fname, err) 168 } 169 170 return nil 171 } 172 173 // gatherTemplates - gather and prepare templates for rendering 174 // 175 //nolint:gocyclo 176 func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(context.Context, string) (string, error)) (templates []Template, err error) { 177 mode, modeOverride, err := cfg.GetMode() 178 if err != nil { 179 return nil, err 180 } 181 182 switch { 183 // the arg-provided input string gets a special name 184 case cfg.Input != "": 185 // open the output file - no need to close it, as it will be closed by the 186 // caller later 187 target, oerr := openOutFile(cfg.OutputFiles[0], 0o755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty) 188 if oerr != nil { 189 return nil, oerr 190 } 191 192 templates = []Template{{ 193 Name: "<arg>", 194 Text: cfg.Input, 195 Writer: target, 196 }} 197 case cfg.InputDir != "": 198 // input dirs presume output dirs are set too 199 templates, err = walkDir(ctx, cfg, cfg.InputDir, outFileNamer, cfg.ExcludeGlob, mode, modeOverride) 200 if err != nil { 201 return nil, err 202 } 203 case cfg.Input == "": 204 templates = make([]Template, len(cfg.InputFiles)) 205 for i := range cfg.InputFiles { 206 templates[i], err = fileToTemplate(cfg, cfg.InputFiles[i], cfg.OutputFiles[i], mode, modeOverride) 207 if err != nil { 208 return nil, err 209 } 210 } 211 } 212 213 return templates, nil 214 } 215 216 // walkDir - given an input dir `dir` and an output dir `outDir`, and a list 217 // of .gomplateignore and exclude globs (if any), walk the input directory and create a list of 218 // tplate objects, and an error, if any. 219 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) { 220 dir = filepath.Clean(dir) 221 222 dirStat, err := aferoFS.Stat(dir) 223 if err != nil { 224 return nil, fmt.Errorf("couldn't stat %s: %w", dir, err) 225 } 226 dirMode := dirStat.Mode() 227 228 templates := make([]Template, 0) 229 matcher := xignore.NewMatcher(aferoFS) 230 231 // work around bug in xignore - a basedir of '.' doesn't work 232 basedir := dir 233 if basedir == "." { 234 basedir, _ = os.Getwd() 235 } 236 matches, err := matcher.Matches(basedir, &xignore.MatchesOptions{ 237 Ignorefile: gomplateignore, 238 Nested: true, // allow nested ignorefile 239 AfterPatterns: excludeGlob, 240 }) 241 if err != nil { 242 return nil, fmt.Errorf("ignore matching failed for %s: %w", basedir, err) 243 } 244 245 // Unmatched ignorefile rules's files 246 files := matches.UnmatchedFiles 247 for _, file := range files { 248 inFile := filepath.Join(dir, file) 249 outFile, err := outFileNamer(ctx, file) 250 if err != nil { 251 return nil, err 252 } 253 254 tpl, err := fileToTemplate(cfg, inFile, outFile, mode, modeOverride) 255 if err != nil { 256 return nil, err 257 } 258 259 // Ensure file parent dirs 260 if err = aferoFS.MkdirAll(filepath.Dir(outFile), dirMode); err != nil { 261 return nil, err 262 } 263 264 templates = append(templates, tpl) 265 } 266 267 return templates, nil 268 } 269 270 func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode, modeOverride bool) (Template, error) { 271 source := "" 272 273 //nolint:nestif 274 if inFile == "-" { 275 b, err := io.ReadAll(cfg.Stdin) 276 if err != nil { 277 return Template{}, fmt.Errorf("failed to read from stdin: %w", err) 278 } 279 280 source = string(b) 281 } else { 282 si, err := aferoFS.Stat(inFile) 283 if err != nil { 284 return Template{}, err 285 } 286 if mode == 0 { 287 mode = si.Mode() 288 } 289 290 // we read the file and store in memory immediately, to prevent leaking 291 // file descriptors. 292 f, err := aferoFS.OpenFile(inFile, os.O_RDONLY, 0) 293 if err != nil { 294 return Template{}, fmt.Errorf("failed to open %s: %w", inFile, err) 295 } 296 297 defer f.Close() 298 299 b, err := io.ReadAll(f) 300 if err != nil { 301 return Template{}, fmt.Errorf("failed to read %s: %w", inFile, err) 302 } 303 304 source = string(b) 305 } 306 307 // open the output file - no need to close it, as it will be closed by the 308 // caller later 309 target, err := openOutFile(outFile, 0o755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty) 310 if err != nil { 311 return Template{}, err 312 } 313 314 tmpl := Template{ 315 Name: inFile, 316 Text: source, 317 Writer: target, 318 } 319 320 return tmpl, nil 321 } 322 323 // openOutFile returns a writer for the given file, creating the file if it 324 // doesn't exist yet, and creating the parent directories if necessary. Will 325 // defer actual opening until the first write (or the first non-empty write if 326 // 'suppressEmpty' is true). If the file already exists, it will not be 327 // overwritten until the first difference is encountered. 328 // 329 // TODO: the 'suppressEmpty' behaviour should be always enabled, in the next 330 // major release (v4.x). 331 func openOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool, stdout io.Writer, suppressEmpty bool) (out io.Writer, err error) { 332 if suppressEmpty { 333 out = iohelpers.NewEmptySkipper(func() (io.Writer, error) { 334 if filename == "-" { 335 return stdout, nil 336 } 337 return createOutFile(filename, dirMode, mode, modeOverride) 338 }) 339 return out, nil 340 } 341 342 if filename == "-" { 343 return stdout, nil 344 } 345 return createOutFile(filename, dirMode, mode, modeOverride) 346 } 347 348 func createOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool) (out io.WriteCloser, err error) { 349 mode = iohelpers.NormalizeFileMode(mode.Perm()) 350 if modeOverride { 351 err = aferoFS.Chmod(filename, mode) 352 if err != nil && !os.IsNotExist(err) { 353 return nil, fmt.Errorf("failed to chmod output file '%s' with mode %q: %w", filename, mode, err) 354 } 355 } 356 357 open := func() (out io.WriteCloser, err error) { 358 // Ensure file parent dirs 359 if err = aferoFS.MkdirAll(filepath.Dir(filename), dirMode); err != nil { 360 return nil, err 361 } 362 363 out, err = aferoFS.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode) 364 if err != nil { 365 return out, fmt.Errorf("failed to open output file '%s' for writing: %w", filename, err) 366 } 367 368 return out, err 369 } 370 371 // if the output file already exists, we'll use a SameSkipper 372 fi, err := aferoFS.Stat(filename) 373 if err != nil { 374 // likely means the file just doesn't exist - further errors will be more useful 375 return iohelpers.LazyWriteCloser(open), nil 376 } 377 if fi.IsDir() { 378 // error because this is a directory 379 return nil, isDirError(fi.Name()) 380 } 381 382 out = iohelpers.SameSkipper(iohelpers.LazyReadCloser(func() (io.ReadCloser, error) { 383 return aferoFS.OpenFile(filename, os.O_RDONLY, mode) 384 }), open) 385 386 return out, err 387 }