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  }