github.com/hairyhenderson/gomplate/v4@v4.0.0-pre-2.0.20240520121557-362f058f0c93/template.go (about) 1 package gomplate 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "io" 8 "io/fs" 9 "os" 10 "path" 11 "path/filepath" 12 "slices" 13 "strings" 14 "text/template" 15 16 "github.com/hack-pad/hackpadfs" 17 "github.com/hairyhenderson/go-fsimpl" 18 "github.com/hairyhenderson/gomplate/v4/internal/config" 19 "github.com/hairyhenderson/gomplate/v4/internal/datafs" 20 "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" 21 "github.com/hairyhenderson/gomplate/v4/tmpl" 22 23 // TODO: switch back if/when fs.FS support gets merged upstream 24 "github.com/hairyhenderson/xignore" 25 ) 26 27 // ignorefile name, like .gitignore 28 const gomplateignore = ".gomplateignore" 29 30 func addTmplFuncs(f template.FuncMap, root *template.Template, tctx interface{}, path string) { 31 t := tmpl.New(root, tctx, path) 32 tns := func() *tmpl.Template { return t } 33 f["tmpl"] = tns 34 f["tpl"] = t.Inline 35 } 36 37 // copyFuncMap - copies the template.FuncMap into a new map so we can modify it 38 // without affecting the original 39 func copyFuncMap(funcMap template.FuncMap) template.FuncMap { 40 if funcMap == nil { 41 return nil 42 } 43 44 newFuncMap := make(template.FuncMap, len(funcMap)) 45 for k, v := range funcMap { 46 newFuncMap[k] = v 47 } 48 return newFuncMap 49 } 50 51 // parseTemplate - parses text as a Go template with the given name and options 52 func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMap, tmplctx interface{}, nested config.Templates, leftDelim, rightDelim string, missingKey string) (tmpl *template.Template, err error) { 53 tmpl = template.New(name) 54 if missingKey == "" { 55 missingKey = "error" 56 } 57 58 missingKeyValues := []string{"error", "zero", "default", "invalid"} 59 if !slices.Contains(missingKeyValues, missingKey) { 60 return nil, fmt.Errorf("not allowed value for the 'missing-key' flag: %s. Allowed values: %s", missingKey, strings.Join(missingKeyValues, ",")) 61 } 62 63 tmpl.Option("missingkey=" + missingKey) 64 65 funcMap := copyFuncMap(funcs) 66 67 // the "tmpl" funcs get added here because they need access to the root template and context 68 addTmplFuncs(funcMap, tmpl, tmplctx, name) 69 tmpl.Funcs(funcMap) 70 tmpl.Delims(leftDelim, rightDelim) 71 _, err = tmpl.Parse(text) 72 if err != nil { 73 return nil, err 74 } 75 76 err = parseNestedTemplates(ctx, nested, tmpl) 77 if err != nil { 78 return nil, fmt.Errorf("parse nested templates: %w", err) 79 } 80 81 return tmpl, nil 82 } 83 84 func parseNestedTemplates(ctx context.Context, nested config.Templates, tmpl *template.Template) error { 85 fsp := datafs.FSProviderFromContext(ctx) 86 87 for alias, n := range nested { 88 u := *n.URL 89 90 fname := path.Base(u.Path) 91 if strings.HasSuffix(u.Path, "/") { 92 fname = "." 93 } 94 95 u.Path = path.Dir(u.Path) 96 97 fsys, err := fsp.New(&u) 98 if err != nil { 99 return fmt.Errorf("filesystem provider for %q unavailable: %w", &u, err) 100 } 101 102 // TODO: maybe need to do something with root here? 103 _, reldir, err := datafs.ResolveLocalPath(fsys, u.Path) 104 if err != nil { 105 return fmt.Errorf("resolveLocalPath: %w", err) 106 } 107 108 if reldir != "" && reldir != "." { 109 fsys, err = fs.Sub(fsys, reldir) 110 if err != nil { 111 return fmt.Errorf("sub filesystem for %q unavailable: %w", &u, err) 112 } 113 } 114 115 // inject context & header in case they're useful... 116 fsys = fsimpl.WithContextFS(ctx, fsys) 117 fsys = fsimpl.WithHeaderFS(n.Header, fsys) 118 119 // valid fs.FS paths have no trailing slash 120 fname = strings.TrimRight(fname, "/") 121 122 // first determine if the template path is a directory, in which case we 123 // need to load all the files in the directory (but not recursively) 124 fi, err := fs.Stat(fsys, fname) 125 if err != nil { 126 return fmt.Errorf("stat %q: %w", fname, err) 127 } 128 129 if fi.IsDir() { 130 err = parseNestedTemplateDir(ctx, fsys, alias, fname, tmpl) 131 } else { 132 err = parseNestedTemplate(ctx, fsys, alias, fname, tmpl) 133 } 134 135 if err != nil { 136 return err 137 } 138 } 139 140 return nil 141 } 142 143 func parseNestedTemplateDir(ctx context.Context, fsys fs.FS, alias, fname string, tmpl *template.Template) error { 144 files, err := fs.ReadDir(fsys, fname) 145 if err != nil { 146 return fmt.Errorf("readDir %q: %w", fname, err) 147 } 148 149 for _, f := range files { 150 if !f.IsDir() { 151 err = parseNestedTemplate(ctx, fsys, 152 path.Join(alias, f.Name()), 153 path.Join(fname, f.Name()), 154 tmpl, 155 ) 156 if err != nil { 157 return err 158 } 159 } 160 } 161 162 return nil 163 } 164 165 func parseNestedTemplate(_ context.Context, fsys fs.FS, alias, fname string, tmpl *template.Template) error { 166 b, err := fs.ReadFile(fsys, fname) 167 if err != nil { 168 return fmt.Errorf("readFile %q: %w", fname, err) 169 } 170 171 _, err = tmpl.New(alias).Parse(string(b)) 172 if err != nil { 173 return fmt.Errorf("parse nested template %q: %w", fname, err) 174 } 175 176 return nil 177 } 178 179 // gatherTemplates - gather and prepare templates for rendering 180 // 181 //nolint:gocyclo 182 func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(context.Context, string) (string, error)) ([]Template, error) { 183 mode, modeOverride, err := cfg.GetMode() 184 if err != nil { 185 return nil, err 186 } 187 188 var templates []Template 189 190 switch { 191 // the arg-provided input string gets a special name 192 case cfg.Input != "": 193 // open the output file - no need to close it, as it will be closed by the 194 // caller later 195 target, oerr := openOutFile(ctx, cfg.OutputFiles[0], 0o755, mode, modeOverride, cfg.Stdout) 196 if oerr != nil { 197 return nil, fmt.Errorf("openOutFile: %w", oerr) 198 } 199 200 templates = []Template{{ 201 Name: "<arg>", 202 Text: cfg.Input, 203 Writer: target, 204 }} 205 case cfg.InputDir != "": 206 // input dirs presume output dirs are set too 207 templates, err = walkDir(ctx, cfg, cfg.InputDir, outFileNamer, cfg.ExcludeGlob, cfg.ExcludeProcessingGlob, mode, modeOverride) 208 if err != nil { 209 return nil, fmt.Errorf("walkDir: %w", err) 210 } 211 case cfg.Input == "": 212 templates = make([]Template, len(cfg.InputFiles)) 213 for i, f := range cfg.InputFiles { 214 templates[i], err = fileToTemplate(ctx, cfg, f, cfg.OutputFiles[i], mode, modeOverride) 215 if err != nil { 216 return nil, fmt.Errorf("fileToTemplate: %w", err) 217 } 218 } 219 } 220 221 return templates, nil 222 } 223 224 // walkDir - given an input dir `dir` and an output dir `outDir`, and a list 225 // of .gomplateignore and exclude globs (if any), walk the input directory and create a list of 226 // tplate objects, and an error, if any. 227 func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer func(context.Context, string) (string, error), excludeGlob []string, excludeProcessingGlob []string, mode os.FileMode, modeOverride bool) ([]Template, error) { 228 dir = filepath.ToSlash(filepath.Clean(dir)) 229 230 // get a filesystem rooted in the same volume as dir (or / on non-Windows) 231 fsys, err := datafs.FSysForPath(ctx, dir) 232 if err != nil { 233 return nil, err 234 } 235 236 // we need dir to be relative to the root of fsys 237 // TODO: maybe need to do something with root here? 238 _, resolvedDir, err := datafs.ResolveLocalPath(fsys, dir) 239 if err != nil { 240 return nil, fmt.Errorf("resolveLocalPath: %w", err) 241 } 242 243 // we need to sub the filesystem to the dir 244 subfsys, err := fs.Sub(fsys, resolvedDir) 245 if err != nil { 246 return nil, fmt.Errorf("sub: %w", err) 247 } 248 249 // just check . because fsys is subbed to dir already 250 dirStat, err := fs.Stat(subfsys, ".") 251 if err != nil { 252 return nil, fmt.Errorf("stat %q (%q): %w", dir, resolvedDir, err) 253 } 254 dirMode := dirStat.Mode() 255 256 templates := make([]Template, 0) 257 matcher := xignore.NewMatcher(subfsys) 258 259 excludeMatches, err := matcher.Matches(".", &xignore.MatchesOptions{ 260 Ignorefile: gomplateignore, 261 Nested: true, // allow nested ignorefile 262 AfterPatterns: excludeGlob, 263 }) 264 if err != nil { 265 return nil, fmt.Errorf("ignore matching failed for %s: %w", dir, err) 266 } 267 268 excludeProcessingMatches, err := matcher.Matches(".", &xignore.MatchesOptions{ 269 // TODO: fix or replace xignore module so we can avoid attempting to read the .gomplateignore file for both exclude and excludeProcessing patterns 270 Ignorefile: gomplateignore, 271 Nested: true, // allow nested ignorefile 272 AfterPatterns: excludeProcessingGlob, 273 }) 274 if err != nil { 275 return nil, fmt.Errorf("passthough matching failed for %s: %w", dir, err) 276 } 277 278 passthroughFiles := make(map[string]bool) 279 280 for _, file := range excludeProcessingMatches.MatchedFiles { 281 // files that need to be directly copied 282 passthroughFiles[file] = true 283 } 284 285 // Unmatched ignorefile rules's files 286 for _, file := range excludeMatches.UnmatchedFiles { 287 // we want to pass an absolute (as much as possible) path to fileToTemplate 288 inPath := filepath.Join(dir, file) 289 inPath = filepath.ToSlash(inPath) 290 291 // but outFileNamer expects only the filename itself 292 outFile, err := outFileNamer(ctx, file) 293 if err != nil { 294 return nil, fmt.Errorf("outFileNamer: %w", err) 295 } 296 297 _, ok := passthroughFiles[file] 298 if ok { 299 err = copyFileToOutDir(ctx, cfg, inPath, outFile, mode, modeOverride) 300 if err != nil { 301 return nil, fmt.Errorf("copyFileToOutDir: %w", err) 302 } 303 304 continue 305 } 306 307 tpl, err := fileToTemplate(ctx, cfg, inPath, outFile, mode, modeOverride) 308 if err != nil { 309 return nil, fmt.Errorf("fileToTemplate: %w", err) 310 } 311 312 // Ensure file parent dirs - use separate fsys for output file 313 outfsys, err := datafs.FSysForPath(ctx, outFile) 314 if err != nil { 315 return nil, fmt.Errorf("fsysForPath: %w", err) 316 } 317 if err = hackpadfs.MkdirAll(outfsys, filepath.Dir(outFile), dirMode); err != nil { 318 return nil, fmt.Errorf("mkdirAll %q: %w", outFile, err) 319 } 320 321 templates = append(templates, tpl) 322 } 323 324 return templates, nil 325 } 326 327 func readInFile(ctx context.Context, cfg *config.Config, inFile string, mode os.FileMode) (source string, newmode os.FileMode, err error) { 328 newmode = mode 329 var b []byte 330 331 //nolint:nestif 332 if inFile == "-" { 333 b, err = io.ReadAll(cfg.Stdin) 334 if err != nil { 335 return source, newmode, fmt.Errorf("read from stdin: %w", err) 336 } 337 338 source = string(b) 339 } else { 340 var fsys fs.FS 341 var si fs.FileInfo 342 fsys, err = datafs.FSysForPath(ctx, inFile) 343 if err != nil { 344 return source, newmode, fmt.Errorf("fsysForPath: %w", err) 345 } 346 347 si, err = fs.Stat(fsys, inFile) 348 if err != nil { 349 return source, newmode, fmt.Errorf("stat %q: %w", inFile, err) 350 } 351 if mode == 0 { 352 newmode = si.Mode() 353 } 354 355 // we read the file and store in memory immediately, to prevent leaking 356 // file descriptors. 357 b, err = fs.ReadFile(fsys, inFile) 358 if err != nil { 359 return source, newmode, fmt.Errorf("readAll %q: %w", inFile, err) 360 } 361 362 source = string(b) 363 } 364 return source, newmode, err 365 } 366 367 func getOutfileHandler(ctx context.Context, cfg *config.Config, outFile string, mode os.FileMode, modeOverride bool) (io.Writer, error) { 368 // open the output file - no need to close it, as it will be closed by the 369 // caller later 370 target, err := openOutFile(ctx, outFile, 0o755, mode, modeOverride, cfg.Stdout) 371 if err != nil { 372 return nil, fmt.Errorf("openOutFile: %w", err) 373 } 374 375 return target, nil 376 } 377 378 func copyFileToOutDir(ctx context.Context, cfg *config.Config, inFile, outFile string, mode os.FileMode, modeOverride bool) error { 379 sourceStr, newmode, err := readInFile(ctx, cfg, inFile, mode) 380 if err != nil { 381 return err 382 } 383 384 outFH, err := getOutfileHandler(ctx, cfg, outFile, newmode, modeOverride) 385 if err != nil { 386 return err 387 } 388 389 wr, ok := outFH.(io.Closer) 390 if ok && wr != os.Stdout { 391 defer wr.Close() 392 } 393 394 _, err = outFH.Write([]byte(sourceStr)) 395 return err 396 } 397 398 func fileToTemplate(ctx context.Context, cfg *config.Config, inFile, outFile string, mode os.FileMode, modeOverride bool) (Template, error) { 399 source, newmode, err := readInFile(ctx, cfg, inFile, mode) 400 if err != nil { 401 return Template{}, err 402 } 403 404 target, err := getOutfileHandler(ctx, cfg, outFile, newmode, modeOverride) 405 if err != nil { 406 return Template{}, err 407 } 408 409 tmpl := Template{ 410 Name: inFile, 411 Text: source, 412 Writer: target, 413 } 414 415 return tmpl, nil 416 } 417 418 // openOutFile returns a writer for the given file, creating the file if it 419 // doesn't exist yet, and creating the parent directories if necessary. Will 420 // defer actual opening until the first non-empty write. If the file already 421 // exists, it will not be overwritten until the first difference is encountered. 422 // 423 // TODO: dirMode is always called with 0o755 - should either remove or make it configurable 424 // 425 //nolint:unparam 426 func openOutFile(ctx context.Context, filename string, dirMode, mode os.FileMode, modeOverride bool, stdout io.Writer) (out io.Writer, err error) { 427 out = iohelpers.NewEmptySkipper(func() (io.Writer, error) { 428 if filename == "-" { 429 return iohelpers.NopCloser(stdout), nil 430 } 431 return createOutFile(ctx, filename, dirMode, mode, modeOverride) 432 }) 433 return out, nil 434 } 435 436 func createOutFile(ctx context.Context, filename string, dirMode, mode os.FileMode, modeOverride bool) (out io.WriteCloser, err error) { 437 // we only support writing out to local files for now 438 fsys, err := datafs.FSysForPath(ctx, filename) 439 if err != nil { 440 return nil, fmt.Errorf("fsysForPath: %w", err) 441 } 442 443 mode = iohelpers.NormalizeFileMode(mode.Perm()) 444 if modeOverride { 445 err = hackpadfs.Chmod(fsys, filename, mode) 446 if err != nil && !errors.Is(err, fs.ErrNotExist) { 447 return nil, fmt.Errorf("failed to chmod output file %q with mode %q: %w", filename, mode, err) 448 } 449 } 450 451 open := func() (out io.WriteCloser, err error) { 452 // Ensure file parent dirs 453 if err = hackpadfs.MkdirAll(fsys, filepath.Dir(filename), dirMode); err != nil { 454 return nil, fmt.Errorf("mkdirAll %q: %w", filename, err) 455 } 456 457 f, err := hackpadfs.OpenFile(fsys, filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode) 458 if err != nil { 459 return out, fmt.Errorf("failed to open output file '%s' for writing: %w", filename, err) 460 } 461 out = f.(io.WriteCloser) 462 463 return out, err 464 } 465 466 // if the output file already exists, we'll use a SameSkipper 467 fi, err := hackpadfs.Stat(fsys, filename) 468 if err != nil { 469 // likely means the file just doesn't exist - further errors will be more useful 470 return iohelpers.LazyWriteCloser(open), nil 471 } 472 if fi.IsDir() { 473 // error because this is a directory 474 return nil, isDirError(fi.Name()) 475 } 476 477 out = iohelpers.SameSkipper(iohelpers.LazyReadCloser(func() (io.ReadCloser, error) { 478 return hackpadfs.OpenFile(fsys, filename, os.O_RDONLY, mode) 479 }), open) 480 481 return out, err 482 }