Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions tools/template-check/cmd/funccheck.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions tools/template-check/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
136 changes: 136 additions & 0 deletions tools/template-check/gotemplate/funccheck.go
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 40 additions & 0 deletions tools/template-check/gotemplate/funccheck_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading