Skip to content

Commit 758d184

Browse files
author
razvan
committed
fix(gotemplate): address PR #47 review + Go↔Template AST linking
- Add scanner.Err() check to prevent silent data truncation - Tighten regex patterns (reBlock/reTemplate/reRange
1 parent e65d513 commit 758d184

8 files changed

Lines changed: 268 additions & 35 deletions

File tree

pkg/parser/go/analyzer.go

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ func (ca *CodeAnalyzer) analyzeFunctionDecl(fset *token.FileSet, fn *doc.Func, a
231231
info.Parameters = ca.extractParameters(fn.Decl.Type.Params)
232232
info.Returns = ca.extractReturns(fn.Decl.Type.Results)
233233
}
234-
info.Calls = ca.extractCallsFromAST(astBody)
234+
info.Calls, info.TemplateFiles = ca.extractCallsFromAST(astBody)
235235
} else if fn.Decl != nil {
236236
// Fallback to doc.Func Decl (won't have Body)
237237
// Extract position information
@@ -761,6 +761,24 @@ func convertPackageInfoToChunks(pi *PackageInfo) []CodeChunk {
761761
Type: pkgParser.RelCalls,
762762
})
763763
}
764+
// Template file dependencies: template.ParseFiles("layout.html") → RelDependency
765+
for _, tplFile := range fn.TemplateFiles {
766+
rels = append(rels, pkgParser.Relation{
767+
TargetName: tplFile,
768+
Type: pkgParser.RelDependency,
769+
})
770+
}
771+
772+
metadata := map[string]any{
773+
"receiver": fn.Receiver,
774+
"is_method": fn.IsMethod,
775+
"params": fn.Parameters,
776+
"returns": fn.Returns,
777+
"examples": fn.Examples,
778+
}
779+
if len(fn.TemplateFiles) > 0 {
780+
metadata["template_files"] = fn.TemplateFiles
781+
}
764782

765783
out = append(out, CodeChunk{
766784
Type: kind,
@@ -774,13 +792,7 @@ func convertPackageInfoToChunks(pi *PackageInfo) []CodeChunk {
774792
Docstring: fn.Description,
775793
Code: fn.Code,
776794
Relations: rels,
777-
Metadata: map[string]any{
778-
"receiver": fn.Receiver,
779-
"is_method": fn.IsMethod,
780-
"params": fn.Parameters,
781-
"returns": fn.Returns,
782-
"examples": fn.Examples,
783-
},
795+
Metadata: metadata,
784796
})
785797
}
786798

@@ -925,29 +937,52 @@ func (ca *CodeAnalyzer) extractCodeFromFile(filePath string, startLine, endLine
925937
return strings.Join(lines, "\n"), nil
926938
}
927939

928-
func (ca *CodeAnalyzer) extractCallsFromAST(body *ast.BlockStmt) []string {
940+
func (ca *CodeAnalyzer) extractCallsFromAST(body *ast.BlockStmt) ([]string, []string) {
929941
if body == nil {
930-
return nil
942+
return nil, nil
931943
}
932944
var calls []string
945+
var templateFiles []string
933946
seen := make(map[string]bool)
947+
seenTpl := make(map[string]bool)
948+
949+
// Template-related function names that receive file paths as string arguments.
950+
templateFuncs := map[string]bool{
951+
"ParseFiles": true, "ParseGlob": true,
952+
}
934953

935954
ast.Inspect(body, func(n ast.Node) bool {
936955
if call, ok := n.(*ast.CallExpr); ok {
937956
var name string
957+
var sel string // selector part (e.g. "template" in template.ParseFiles)
938958
switch fun := call.Fun.(type) {
939959
case *ast.Ident:
940960
name = fun.Name
941961
case *ast.SelectorExpr:
942962
x := ca.typeToString(fun.X)
943-
name = fmt.Sprintf("%s.%s", x, fun.Sel.Name)
963+
sel = fun.Sel.Name
964+
name = fmt.Sprintf("%s.%s", x, sel)
944965
}
945966
if name != "" && !seen[name] {
946967
calls = append(calls, name)
947968
seen[name] = true
948969
}
970+
971+
// Extract template file paths from template.ParseFiles("a.html", "b.html")
972+
// and similar calls (works on any receiver: t.ParseFiles, tpl.ParseFiles, etc.)
973+
if templateFuncs[sel] {
974+
for _, arg := range call.Args {
975+
if lit, ok := arg.(*ast.BasicLit); ok && lit.Kind == token.STRING {
976+
path := strings.Trim(lit.Value, "\"`")
977+
if path != "" && !seenTpl[path] {
978+
seenTpl[path] = true
979+
templateFiles = append(templateFiles, path)
980+
}
981+
}
982+
}
983+
}
949984
}
950985
return true
951986
})
952-
return calls
987+
return calls, templateFiles
953988
}

pkg/parser/go/relations_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,57 @@ func TestGoRelations_TypesAreCanonical(t *testing.T) {
100100
}
101101
}
102102
}
103+
104+
const goTemplateUsageCode = `package web
105+
106+
import "html/template"
107+
108+
func RenderPage(w io.Writer, data any) error {
109+
t, err := template.ParseFiles("templates/layout.html", "templates/header.html")
110+
if err != nil {
111+
return err
112+
}
113+
return t.Execute(w, data)
114+
}
115+
116+
func RenderDashboard(w io.Writer, data any) error {
117+
t := template.Must(template.ParseGlob("templates/dashboard/*.tmpl"))
118+
return t.Execute(w, data)
119+
}
120+
`
121+
122+
func TestGoRelations_TemplateFileDependencies(t *testing.T) {
123+
tmpDir := t.TempDir()
124+
f := filepath.Join(tmpDir, "handler.go")
125+
require.NoError(t, os.WriteFile(f, []byte(goTemplateUsageCode), 0644))
126+
127+
ca := NewCodeAnalyzer()
128+
res, err := ca.Analyze(context.Background(), tmpDir)
129+
require.NoError(t, err)
130+
131+
renderPage := findGoSymbol(res.Symbols, "RenderPage")
132+
require.NotNil(t, renderPage, "RenderPage function symbol must exist")
133+
134+
// Should have dependency relations to template file paths
135+
assert.True(t, hasGoRelation(renderPage.Relations, "templates/layout.html", pkgParser.RelDependency),
136+
"RenderPage should have dependency→templates/layout.html; got %v", renderPage.Relations)
137+
assert.True(t, hasGoRelation(renderPage.Relations, "templates/header.html", pkgParser.RelDependency),
138+
"RenderPage should have dependency→templates/header.html; got %v", renderPage.Relations)
139+
140+
// Should have template_files in metadata
141+
if tplFiles, ok := renderPage.Metadata["template_files"]; ok {
142+
files, ok := tplFiles.([]string)
143+
assert.True(t, ok, "template_files metadata should be []string")
144+
assert.Contains(t, files, "templates/layout.html")
145+
assert.Contains(t, files, "templates/header.html")
146+
} else {
147+
t.Error("expected template_files metadata on RenderPage")
148+
}
149+
150+
// RenderDashboard: ParseGlob with glob pattern
151+
renderDash := findGoSymbol(res.Symbols, "RenderDashboard")
152+
require.NotNil(t, renderDash, "RenderDashboard function symbol must exist")
153+
154+
assert.True(t, hasGoRelation(renderDash.Relations, "templates/dashboard/*.tmpl", pkgParser.RelDependency),
155+
"RenderDashboard should have dependency→templates/dashboard/*.tmpl; got %v", renderDash.Relations)
156+
}

pkg/parser/go/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ type FunctionInfo struct {
5151
EndLine int `json:"end_line,omitempty"`
5252
Code string `json:"code,omitempty"`
5353
Calls []string `json:"calls,omitempty"`
54+
TemplateFiles []string `json:"template_files,omitempty"` // Go template file paths from template.ParseFiles() etc.
5455
}
5556

5657
// TypeInfo describes a type declaration (struct, interface, alias, etc.)

pkg/parser/html/analyzer.go

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,24 +53,26 @@ func (a *Analyzer) CanHandle(filePath string) bool {
5353
func (a *Analyzer) Analyze(ctx context.Context, path string) (*pkgParser.Result, error) {
5454
var symbols []pkgParser.Symbol
5555

56-
// For single files: detect Go template syntax and run GoTemplate analysis
56+
// Detect Go template syntax and run GoTemplate analysis
5757
info, err := os.Stat(path)
5858
if err != nil {
5959
return nil, err
6060
}
6161

6262
if !info.IsDir() {
63-
data, err := os.ReadFile(path)
64-
if err != nil {
65-
return nil, err
66-
}
67-
68-
// If Go template syntax detected, run Go template analysis first
69-
if bytes.Contains(data, []byte("{{")) {
70-
goTplAnalyzer := &gotemplate.GoTemplateAnalyzer{}
71-
templates := goTplAnalyzer.Analyze([]string{path})
72-
symbols = append(symbols, gotemplate.ConvertToSymbols(templates)...)
73-
}
63+
// Single file: check for Go template syntax
64+
symbols = append(symbols, a.analyzeGoTemplates(path)...)
65+
} else {
66+
// Directory: walk and check each HTML file for Go template syntax
67+
filepath.WalkDir(path, func(fp string, d fs.DirEntry, err error) error {
68+
if err != nil || d.IsDir() {
69+
return nil
70+
}
71+
if a.ca.isHTMLFile(d.Name()) {
72+
symbols = append(symbols, a.analyzeGoTemplates(fp)...)
73+
}
74+
return nil
75+
})
7476
}
7577

7678
// Always run HTML DOM analysis too (Go templates contain HTML)
@@ -105,8 +107,19 @@ func (a *Analyzer) Analyze(ctx context.Context, path string) (*pkgParser.Result,
105107
}, nil
106108
}
107109

108-
109-
110+
// analyzeGoTemplates checks a single file for Go template syntax and returns symbols.
111+
func (a *Analyzer) analyzeGoTemplates(filePath string) []pkgParser.Symbol {
112+
data, err := os.ReadFile(filePath)
113+
if err != nil {
114+
return nil
115+
}
116+
if !bytes.Contains(data, []byte("{{")) {
117+
return nil
118+
}
119+
goTplAnalyzer := &gotemplate.GoTemplateAnalyzer{}
120+
templates := goTplAnalyzer.Analyze([]string{filePath})
121+
return gotemplate.ConvertToSymbols(templates)
122+
}
110123
// CodeAnalyzer handles the heavy lifting of HTML analysis.
111124
type CodeAnalyzer struct{}
112125

pkg/parser/html/gotemplate/adapter.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99
)
1010

1111
// ConvertToSymbols converts parsed GoTemplate results to parser.Symbol entries
12-
// with structural relations (dependency for {{ template }}, inheritance-like for {{ block }}).
12+
// with structural relations:
13+
// - RelDependency for {{ template "name" }} includes
14+
// - RelInheritance for {{ block "name" }} (defines overridable default content)
1315
func ConvertToSymbols(templates []GoTemplate) []pkgParser.Symbol {
1416
var symbols []pkgParser.Symbol
1517

@@ -84,6 +86,13 @@ func buildFileSymbol(tpl GoTemplate, nameNoExt string) pkgParser.Symbol {
8486
Type: pkgParser.RelDependency,
8587
})
8688
}
89+
// Build relations: blocks → inheritance (overridable default content)
90+
for _, blk := range tpl.Blocks {
91+
relations = append(relations, pkgParser.Relation{
92+
TargetName: blk.Name,
93+
Type: pkgParser.RelInheritance,
94+
})
95+
}
8796

8897
endLine := tpl.TotalLines
8998
if endLine < 1 {

pkg/parser/html/gotemplate/analyzer.go

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,23 @@ package gotemplate
33
import (
44
"bufio"
55
"os"
6+
"path/filepath"
67
"regexp"
78
"strings"
9+
10+
"github.com/doITmagic/rag-code-mcp/internal/logger"
811
)
912

1013
// Regex patterns for Go template directives.
1114
var (
1215
reDefine = regexp.MustCompile(`\{\{-?\s*define\s+"([^"]+)"\s*-?\}\}`)
13-
reBlock = regexp.MustCompile(`\{\{-?\s*block\s+"([^"]+)"\s*(\.[\w.]*)?`)
14-
reTemplate = regexp.MustCompile(`\{\{-?\s*template\s+"([^"]+)"\s*(\.[\w.]*)?`)
15-
reRange = regexp.MustCompile(`\{\{-?\s*range\s+(\.[\w.]+)`)
16+
reBlock = regexp.MustCompile(`\{\{-?\s*block\s+"([^"]+)"\s*(\.[\w.]*)?\s*-?\}\}`)
17+
reTemplate = regexp.MustCompile(`\{\{-?\s*template\s+"([^"]+)"\s*(\.[\w.]*)?\s*-?\}\}`)
18+
reRange = regexp.MustCompile(`\{\{-?\s*range\s+(\.[\w.]+)\s*-?\}\}`)
1619
reIf = regexp.MustCompile(`\{\{-?\s*if\s+(.+?)\s*-?\}\}`)
20+
reElseIf = regexp.MustCompile(`\{\{-?\s*else\s+if\s+(.+?)\s*-?\}\}`)
1721
reElse = regexp.MustCompile(`\{\{-?\s*else\s*-?\}\}`)
18-
reWith = regexp.MustCompile(`\{\{-?\s*with\s+(\.[\w.]+)`)
22+
reWith = regexp.MustCompile(`\{\{-?\s*with\s+(\.[\w.]+)\s*-?\}\}`)
1923
reEnd = regexp.MustCompile(`\{\{-?\s*end\s*-?\}\}`)
2024
reComment = regexp.MustCompile(`\{\{/\*.*?\*/\}\}`)
2125
reVariable = regexp.MustCompile(`\{\{-?\s*(\.[\w.]+)\s*-?\}\}`)
@@ -43,7 +47,8 @@ func (a *GoTemplateAnalyzer) Analyze(filePaths []string) []GoTemplate {
4347
for _, fp := range filePaths {
4448
tpl, err := a.analyzeFile(fp)
4549
if err != nil {
46-
continue // skip unreadable files
50+
logger.Instance.Debug("[GOTEMPLATE] skip %s: %v", filepath.Base(fp), err)
51+
continue
4752
}
4853
templates = append(templates, tpl)
4954
}
@@ -134,9 +139,23 @@ func (a *GoTemplateAnalyzer) analyzeFile(filePath string) (GoTemplate, error) {
134139
stack = append(stack, openBlock{kind: "if", idx: len(tpl.Conditionals) - 1})
135140
}
136141

137-
// {{ else }}
138-
if reElse.MatchString(line) {
139-
// Find the matching if on the stack
142+
// {{ else if .Cond }} — must check BEFORE bare {{ else }}
143+
if m := reElseIf.FindStringSubmatch(line); m != nil {
144+
// Mark HasElse on the current if
145+
for i := len(stack) - 1; i >= 0; i-- {
146+
if stack[i].kind == "if" {
147+
tpl.Conditionals[stack[i].idx].HasElse = true
148+
break
149+
}
150+
}
151+
// Push a new conditional for the else-if branch
152+
tpl.Conditionals = append(tpl.Conditionals, ConditionalDirective{
153+
Condition: strings.TrimSpace(m[1]),
154+
Line: lineNum,
155+
})
156+
stack = append(stack, openBlock{kind: "if", idx: len(tpl.Conditionals) - 1})
157+
} else if reElse.MatchString(line) {
158+
// {{ else }} — bare else without condition
140159
for i := len(stack) - 1; i >= 0; i-- {
141160
if stack[i].kind == "if" {
142161
tpl.Conditionals[stack[i].idx].HasElse = true
@@ -191,6 +210,10 @@ func (a *GoTemplateAnalyzer) analyzeFile(filePath string) (GoTemplate, error) {
191210
}
192211
}
193212

213+
if err := scanner.Err(); err != nil {
214+
return GoTemplate{}, err
215+
}
216+
194217
tpl.TotalLines = lineNum
195218
return tpl, nil
196219
}

0 commit comments

Comments
 (0)