diff --git a/tools/template-check/cmd/funccheck.go b/tools/template-check/cmd/funccheck.go new file mode 100644 index 000000000000..7a0ecc79b422 --- /dev/null +++ b/tools/template-check/cmd/funccheck.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/GoogleCloudPlatform/magic-modules/tools/template-check/gotemplate" + "github.com/spf13/cobra" +) + +const funcCheckDesc = `Check template files for invalid function calls` + +type funcCheckOptions struct { + rootOptions *rootOptions + stdout io.Writer + fileList []string +} + +func newFuncCheckCmd(rootOptions *rootOptions) *cobra.Command { + o := &funcCheckOptions{ + rootOptions: rootOptions, + stdout: os.Stdout, + } + command := &cobra.Command{ + Use: "func-check", + Short: funcCheckDesc, + Long: funcCheckDesc, + RunE: func(c *cobra.Command, args []string) error { + return o.run() + }, + } + command.Flags().StringSliceVar(&o.fileList, "file-list", []string{}, "file list to check") + return command +} + +func (o *funcCheckOptions) run() error { + if len(o.fileList) == 0 { + return nil + } + foundInvalidFuncs := false + for _, fileName := range o.fileList { + results, err := gotemplate.CheckInvalidFuncsForFile(fileName) + if err != nil { + return err + } + if len(results) > 0 { + fmt.Fprintf(os.Stderr, "%s:\n", fileName) + foundInvalidFuncs = true + for _, result := range results { + fmt.Fprintf(os.Stderr, " %s\n", result) + } + } + } + if foundInvalidFuncs { + return fmt.Errorf("found invalid template function calls") + } + return nil +} diff --git a/tools/template-check/cmd/root.go b/tools/template-check/cmd/root.go index 3b68327ed532..9268fb33fee1 100644 --- a/tools/template-check/cmd/root.go +++ b/tools/template-check/cmd/root.go @@ -23,6 +23,7 @@ func newRootCmd() (*cobra.Command, *rootOptions, error) { } cmd.AddCommand(newversionGuardCmd(o)) cmd.AddCommand(newUnusedTmplCmd(o)) + cmd.AddCommand(newFuncCheckCmd(o)) return cmd, o, nil } diff --git a/tools/template-check/gotemplate/funccheck.go b/tools/template-check/gotemplate/funccheck.go new file mode 100644 index 000000000000..9084a674c6f9 --- /dev/null +++ b/tools/template-check/gotemplate/funccheck.go @@ -0,0 +1,136 @@ +package gotemplate + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" +) + +// actionRegex extracts template actions: everything between {{ and }} +var actionRegex = regexp.MustCompile(`\{\{-?\s*(.*?)\s*-?\}\}`) + +// identifierRegex matches a standalone identifier (function name) at the start of a pipeline segment +var identifierRegex = regexp.MustCompile(`^([a-zA-Z_][a-zA-Z0-9_]*)`) + +// ValidFuncs is the registry of allowed template functions and keywords. +var ValidFuncs = map[string]bool{ + // Go built-in template functions + "and": true, "call": true, "eq": true, "ge": true, "gt": true, "html": true, + "index": true, "js": true, "le": true, "len": true, "lt": true, "ne": true, + "not": true, "or": true, "print": true, "printf": true, "println": true, + "slice": true, "urlquery": true, + + // Go template keywords + "if": true, "else": true, "end": true, "range": true, "with": true, + "block": true, "define": true, "template": true, "nil": true, + + // mmv1 registered functions (google/template_utils.go) + "title": true, "replace": true, "replaceAll": true, "camelize": true, + "underscore": true, "plural": true, "contains": true, "join": true, + "lower": true, "upper": true, "hasSuffix": true, "dict": true, + "format2regex": true, "hasPrefix": true, "sub": true, "plus": true, + "firstSentence": true, "trimTemplate": true, "customTemplate": true, + + // mmv1 registered functions (provider/template_data.go) + "TemplatePath": true, +} + +// CheckInvalidFuncsForFile scans a file for invalid template function calls. +func CheckInvalidFuncsForFile(filename string) ([]string, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + var results []string + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := scanner.Text() + actions := actionRegex.FindAllStringSubmatch(line, -1) + for _, action := range actions { + body := strings.TrimSpace(action[1]) + if body == "" { + continue + } + + segments := strings.Split(body, "|") + for _, seg := range segments { + seg = strings.TrimSpace(seg) + if seg == "" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "$") || strings.HasPrefix(seg, "else if") { + continue + } + + match := identifierRegex.FindStringSubmatch(seg) + if match == nil { + continue + } + + funcName := match[1] + if funcName == "true" || funcName == "false" || ValidFuncs[funcName] { + continue + } + + // Skip bare lowercase identifiers (likely Terraform interpolation) + if seg == funcName && !hasUpperCase(funcName) { + continue + } + + // Skip if it appears in a string literal + if appearsInStringLiteral(body, funcName) { + continue + } + + results = append(results, fmt.Sprintf("unknown function %q in action {{%s}} (line %d)", funcName, body, lineNum)) + } + } + } + return results, scanner.Err() +} + +func hasUpperCase(s string) bool { + for _, ch := range s { + if ch >= 'A' && ch <= 'Z' { + return true + } + } + return false +} + +func appearsInStringLiteral(body string, identifier string) bool { + inString, escaped := false, false + var current strings.Builder + for _, ch := range body { + if escaped { + if inString { + current.WriteRune(ch) + } + escaped = false + continue + } + if ch == '\\' { + escaped = true + if inString { + current.WriteRune(ch) + } + continue + } + if ch == '"' { + if inString && strings.Contains(current.String(), identifier) { + return true + } + current.Reset() + inString = !inString + continue + } + if inString { + current.WriteRune(ch) + } + } + return false +} diff --git a/tools/template-check/gotemplate/funccheck_test.go b/tools/template-check/gotemplate/funccheck_test.go new file mode 100644 index 000000000000..2f9912343383 --- /dev/null +++ b/tools/template-check/gotemplate/funccheck_test.go @@ -0,0 +1,40 @@ +package gotemplate + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func createTestFile(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "test.go.tmpl") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + return path +} + +func TestFuncCheck_Basic(t *testing.T) { + // Test that it catches an invalid function + path := createTestFile(t, `{{BigQueryBasePath}}`) + results, err := CheckInvalidFuncsForFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(results) == 0 || !strings.Contains(results[0], "BigQueryBasePath") { + t.Errorf("expected error for BigQueryBasePath, got: %v", results) + } + + // Test that it passes a valid function + path2 := createTestFile(t, `{{camelize .Name}}`) + results2, err := CheckInvalidFuncsForFile(path2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(results2) != 0 { + t.Errorf("expected no errors for camelize, got: %v", results2) + } +}