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  }