From cc2d09ee2a8450f7350382ddefb73efc7f26756e Mon Sep 17 00:00:00 2001 From: razvan Date: Wed, 18 Mar 2026 23:00:33 +0200 Subject: [PATCH 1/6] feat(html): add Go template parser with structural analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GoTemplateAnalyzer that parses Go template syntax ({{ }}) in HTML, .tmpl, and .gohtml files. Extracts directives (define, block, template, range, if/else, with), variables, custom functions, and comments. Key features: - Regex-based parser similar to BladeAnalyzer - Converts to parser.Symbol with RelDependency relations ({{ template "x" }} creates dependency to template x) - Dual-mode analysis: Go template + HTML DOM for all file types - Detects {{ }} syntax automatically regardless of extension - Stack-based EndLine tracking for nested blocks - Rich metadata: variables, custom_funcs, ranges, blocks Files added: - pkg/parser/html/gotemplate/types.go - Type definitions - pkg/parser/html/gotemplate/analyzer.go - GoTemplateAnalyzer (regex) - pkg/parser/html/gotemplate/adapter.go - GoTemplate → Symbol conversion - pkg/parser/html/gotemplate/testdata/ - Test fixtures Files modified: - pkg/parser/html/analyzer.go - Integrate gotemplate + .tmpl/.gohtml - pkg/parser/html/analyzer_test.go - Integration tests 17/17 tests passing --- pkg/parser/html/analyzer.go | 45 +++- pkg/parser/html/analyzer_test.go | 71 +++++++ pkg/parser/html/gotemplate/adapter.go | 181 ++++++++++++++++ pkg/parser/html/gotemplate/adapter_test.go | 122 +++++++++++ pkg/parser/html/gotemplate/analyzer.go | 196 ++++++++++++++++++ pkg/parser/html/gotemplate/analyzer_test.go | 181 ++++++++++++++++ .../html/gotemplate/testdata/layout.html | 32 +++ pkg/parser/html/gotemplate/testdata/page.tmpl | 12 ++ .../html/gotemplate/testdata/partial.gohtml | 7 + pkg/parser/html/gotemplate/types.go | 60 ++++++ 10 files changed, 901 insertions(+), 6 deletions(-) create mode 100644 pkg/parser/html/gotemplate/adapter.go create mode 100644 pkg/parser/html/gotemplate/adapter_test.go create mode 100644 pkg/parser/html/gotemplate/analyzer.go create mode 100644 pkg/parser/html/gotemplate/analyzer_test.go create mode 100644 pkg/parser/html/gotemplate/testdata/layout.html create mode 100644 pkg/parser/html/gotemplate/testdata/page.tmpl create mode 100644 pkg/parser/html/gotemplate/testdata/partial.gohtml create mode 100644 pkg/parser/html/gotemplate/types.go diff --git a/pkg/parser/html/analyzer.go b/pkg/parser/html/analyzer.go index a9b5794..6f559e5 100644 --- a/pkg/parser/html/analyzer.go +++ b/pkg/parser/html/analyzer.go @@ -8,8 +8,11 @@ import ( "os" "path/filepath" "strings" + "github.com/PuerkitoBio/goquery" + pkgParser "github.com/doITmagic/rag-code-mcp/pkg/parser" + "github.com/doITmagic/rag-code-mcp/pkg/parser/html/gotemplate" ) func init() { @@ -33,26 +36,56 @@ func (a *Analyzer) Name() string { return "html" } -// CanHandle returns true for .html files. +// CanHandle returns true for .html, .htm, .tmpl, and .gohtml files. func (a *Analyzer) CanHandle(filePath string) bool { ext := strings.ToLower(filepath.Ext(filePath)) switch ext { - case ".html", ".htm": + case ".html", ".htm", ".tmpl", ".gohtml": return true default: return false } } -// Analyze extracts symbols (sections) from an HTML file. +// Analyze extracts symbols from an HTML or Go template file. +// For files with {{ }} syntax: uses both GoTemplate and HTML analysis. +// For plain HTML files: uses goquery HTML analysis only. func (a *Analyzer) Analyze(ctx context.Context, path string) (*pkgParser.Result, error) { - // HTML files: use goquery + var symbols []pkgParser.Symbol + + // For single files: detect Go template syntax and run GoTemplate analysis + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !info.IsDir() { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + // If Go template syntax detected, run Go template analysis first + if bytes.Contains(data, []byte("{{")) { + goTplAnalyzer := &gotemplate.GoTemplateAnalyzer{} + templates := goTplAnalyzer.Analyze([]string{path}) + symbols = append(symbols, gotemplate.ConvertToSymbols(templates)...) + } + } + + // Always run HTML DOM analysis too (Go templates contain HTML) chunks, err := a.ca.AnalyzePaths([]string{path}) if err != nil { + // If HTML parsing fails but we got Go template symbols, return those + if len(symbols) > 0 { + return &pkgParser.Result{ + Symbols: symbols, + Language: "html", + }, nil + } return nil, err } - var symbols []pkgParser.Symbol for _, ch := range chunks { symbols = append(symbols, pkgParser.Symbol{ Name: ch.Name, @@ -252,7 +285,7 @@ func (ca *CodeAnalyzer) shouldSkipDir(path, root string) bool { func (ca *CodeAnalyzer) isHTMLFile(name string) bool { lower := strings.ToLower(name) - for _, ext := range []string{".html", ".htm"} { + for _, ext := range []string{".html", ".htm", ".tmpl", ".gohtml"} { if strings.HasSuffix(lower, ext) { return true } diff --git a/pkg/parser/html/analyzer_test.go b/pkg/parser/html/analyzer_test.go index 3555319..4697c50 100644 --- a/pkg/parser/html/analyzer_test.go +++ b/pkg/parser/html/analyzer_test.go @@ -127,4 +127,75 @@ func TestHTMLAnalyzer_Comprehensive(t *testing.T) { assert.NotEqual(t, "Skip", s.Name) } }) + + t.Run("CanHandle_GoTemplateExtensions", func(t *testing.T) { + assert.True(t, analyzer.CanHandle("layout.tmpl")) + assert.True(t, analyzer.CanHandle("partial.gohtml")) + assert.True(t, analyzer.CanHandle("page.HTML")) + }) + + t.Run("HTML_with_GoTemplate_syntax", func(t *testing.T) { + goTplHTML := ` + +{{ .Title }} + +

{{ .PageTitle }}

+ {{ range .Items }} +

{{ .Name }}

+ {{ end }} + {{ template "footer" . }} + +` + goTplPath := filepath.Join(tmpDir, "gotpl.html") + require.NoError(t, os.WriteFile(goTplPath, []byte(goTplHTML), 0644)) + + res, err := analyzer.Analyze(context.Background(), goTplPath) + require.NoError(t, err) + + // Should have BOTH Go template symbols AND HTML symbols + assert.Greater(t, len(res.Symbols), 1, "expected both Go template and HTML symbols") + + // Verify Go template symbol exists with correct metadata + foundGoTpl := false + for _, s := range res.Symbols { + if md, ok := s.Metadata["template_type"]; ok && md == "go_template" { + foundGoTpl = true + // Should have includes relation to "footer" + foundRel := false + for _, rel := range s.Relations { + if rel.TargetName == "footer" { + foundRel = true + } + } + assert.True(t, foundRel, "expected relation to 'footer' template") + } + } + assert.True(t, foundGoTpl, "expected go_template symbol") + }) + + t.Run("Tmpl_file", func(t *testing.T) { + tmplContent := `{{ define "sidebar" }} + +{{ end }}` + tmplPath := filepath.Join(tmpDir, "sidebar.tmpl") + require.NoError(t, os.WriteFile(tmplPath, []byte(tmplContent), 0644)) + + res, err := analyzer.Analyze(context.Background(), tmplPath) + require.NoError(t, err) + + // Should produce Go template symbols + assert.Greater(t, len(res.Symbols), 0) + + foundDefine := false + for _, s := range res.Symbols { + if md, ok := s.Metadata["define_name"]; ok && md == "sidebar" { + foundDefine = true + } + } + assert.True(t, foundDefine, "expected define 'sidebar' symbol") + }) } diff --git a/pkg/parser/html/gotemplate/adapter.go b/pkg/parser/html/gotemplate/adapter.go new file mode 100644 index 0000000..fe5ebcd --- /dev/null +++ b/pkg/parser/html/gotemplate/adapter.go @@ -0,0 +1,181 @@ +package gotemplate + +import ( + "fmt" + "path/filepath" + "strings" + + pkgParser "github.com/doITmagic/rag-code-mcp/pkg/parser" +) + +// ConvertToSymbols converts parsed GoTemplate results to parser.Symbol entries +// with structural relations (dependency for {{ template }}, inheritance-like for {{ block }}). +func ConvertToSymbols(templates []GoTemplate) []pkgParser.Symbol { + var symbols []pkgParser.Symbol + + for _, tpl := range templates { + baseName := filepath.Base(tpl.FilePath) + nameNoExt := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + + // If template has {{ define }} blocks, create a symbol per define. + if len(tpl.Defines) > 0 { + for _, def := range tpl.Defines { + sym := buildDefineSymbol(tpl, def) + symbols = append(symbols, sym) + } + } + + // Always create a file-level symbol for the whole template. + sym := buildFileSymbol(tpl, nameNoExt) + symbols = append(symbols, sym) + } + + return symbols +} + +// buildFileSymbol creates a file-level symbol representing the entire template. +func buildFileSymbol(tpl GoTemplate, nameNoExt string) pkgParser.Symbol { + // Build signature summary + var sigParts []string + sigParts = append(sigParts, "go_template") + if len(tpl.Defines) > 0 { + names := make([]string, len(tpl.Defines)) + for i, d := range tpl.Defines { + names[i] = d.Name + } + sigParts = append(sigParts, fmt.Sprintf("defines: %s", strings.Join(names, ", "))) + } + if len(tpl.TemplateIncludes) > 0 { + names := make([]string, len(tpl.TemplateIncludes)) + for i, t := range tpl.TemplateIncludes { + names[i] = t.Name + } + sigParts = append(sigParts, fmt.Sprintf("includes: %s", strings.Join(names, ", "))) + } + if len(tpl.Blocks) > 0 { + names := make([]string, len(tpl.Blocks)) + for i, b := range tpl.Blocks { + names[i] = b.Name + } + sigParts = append(sigParts, fmt.Sprintf("blocks: %s", strings.Join(names, ", "))) + } + + // Build docstring + var docParts []string + if len(tpl.Variables) > 0 { + docParts = append(docParts, fmt.Sprintf("Variables: %s", strings.Join(tpl.Variables, ", "))) + } + if len(tpl.CustomFuncs) > 0 { + docParts = append(docParts, fmt.Sprintf("Custom funcs: %s", strings.Join(tpl.CustomFuncs, ", "))) + } + if len(tpl.Ranges) > 0 { + vars := make([]string, len(tpl.Ranges)) + for i, r := range tpl.Ranges { + vars[i] = r.Variable + } + docParts = append(docParts, fmt.Sprintf("Iterates: %s", strings.Join(vars, ", "))) + } + + // Build relations: template includes → dependency + var relations []pkgParser.Relation + for _, inc := range tpl.TemplateIncludes { + relations = append(relations, pkgParser.Relation{ + TargetName: inc.Name, + Type: pkgParser.RelDependency, + }) + } + + endLine := tpl.TotalLines + if endLine < 1 { + endLine = 1 + } + + return pkgParser.Symbol{ + Name: nameNoExt, + Type: pkgParser.Type, + FilePath: tpl.FilePath, + Language: "html", + StartLine: 1, + EndLine: endLine, + Signature: strings.Join(sigParts, " | "), + Docstring: strings.Join(docParts, " | "), + IsPublic: true, + Relations: relations, + Metadata: map[string]any{ + "template_type": "go_template", + "defines": extractDefineNames(tpl), + "includes": extractIncludeNames(tpl), + "blocks": extractBlockNames(tpl), + "variables": tpl.Variables, + "custom_funcs": tpl.CustomFuncs, + "ranges": extractRangeVars(tpl), + }, + } +} + +// buildDefineSymbol creates a symbol for a specific {{ define "name" }} block. +func buildDefineSymbol(tpl GoTemplate, def DefineDirective) pkgParser.Symbol { + endLine := def.EndLine + if endLine < def.Line { + endLine = def.Line + } + + // Relations: any {{ template "x" }} inside this define are dependencies + var relations []pkgParser.Relation + for _, inc := range tpl.TemplateIncludes { + if inc.Line >= def.Line && inc.Line <= endLine { + relations = append(relations, pkgParser.Relation{ + TargetName: inc.Name, + Type: pkgParser.RelDependency, + }) + } + } + + return pkgParser.Symbol{ + Name: def.Name, + Type: pkgParser.Type, + FilePath: tpl.FilePath, + Language: "html", + StartLine: def.Line, + EndLine: endLine, + Signature: fmt.Sprintf(`go_template | {{ define "%s" }}`, def.Name), + IsPublic: true, + Relations: relations, + Metadata: map[string]any{ + "template_type": "go_template_define", + "define_name": def.Name, + }, + } +} + +func extractDefineNames(tpl GoTemplate) []string { + names := make([]string, len(tpl.Defines)) + for i, d := range tpl.Defines { + names[i] = d.Name + } + return names +} + +func extractIncludeNames(tpl GoTemplate) []string { + names := make([]string, len(tpl.TemplateIncludes)) + for i, t := range tpl.TemplateIncludes { + names[i] = t.Name + } + return names +} + +func extractBlockNames(tpl GoTemplate) []string { + names := make([]string, len(tpl.Blocks)) + for i, b := range tpl.Blocks { + names[i] = b.Name + } + return names +} + +func extractRangeVars(tpl GoTemplate) []string { + vars := make([]string, len(tpl.Ranges)) + for i, r := range tpl.Ranges { + vars[i] = r.Variable + } + return vars +} diff --git a/pkg/parser/html/gotemplate/adapter_test.go b/pkg/parser/html/gotemplate/adapter_test.go new file mode 100644 index 0000000..f64c37f --- /dev/null +++ b/pkg/parser/html/gotemplate/adapter_test.go @@ -0,0 +1,122 @@ +package gotemplate + +import ( + "testing" + + pkgParser "github.com/doITmagic/rag-code-mcp/pkg/parser" +) + +func TestConvertToSymbols_Layout(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{testdataPath("layout.html")}) + symbols := ConvertToSymbols(templates) + + if len(symbols) == 0 { + t.Fatal("expected at least 1 symbol") + } + + // Should have 2 symbols: one for {{ define "base" }}, one file-level + if len(symbols) != 2 { + t.Fatalf("expected 2 symbols (1 define + 1 file), got %d", len(symbols)) + } + + // First symbol: the define "base" + defSym := symbols[0] + if defSym.Name != "base" { + t.Errorf("expected define symbol name 'base', got %q", defSym.Name) + } + if defSym.Metadata["template_type"] != "go_template_define" { + t.Errorf("expected template_type=go_template_define, got %v", defSym.Metadata["template_type"]) + } + // Should have relation to "nav" ({{ template "nav" }} is inside define "base") + foundNavRel := false + for _, rel := range defSym.Relations { + if rel.TargetName == "nav" && rel.Type == pkgParser.RelDependency { + foundNavRel = true + } + } + if !foundNavRel { + t.Errorf("expected relation to 'nav', got %v", defSym.Relations) + } + + // Second symbol: file-level + fileSym := symbols[1] + if fileSym.Metadata["template_type"] != "go_template" { + t.Errorf("expected template_type=go_template, got %v", fileSym.Metadata["template_type"]) + } + // Signature should contain "defines: base" + if fileSym.Signature == "" { + t.Error("expected non-empty signature") + } + + // File-level should have includes relation + foundIncRel := false + for _, rel := range fileSym.Relations { + if rel.TargetName == "nav" && rel.Type == pkgParser.RelDependency { + foundIncRel = true + } + } + if !foundIncRel { + t.Errorf("expected file-level relation to 'nav', got %v", fileSym.Relations) + } +} + +func TestConvertToSymbols_Partial(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{testdataPath("partial.gohtml")}) + symbols := ConvertToSymbols(templates) + + // No defines → only 1 file-level symbol + if len(symbols) != 1 { + t.Fatalf("expected 1 symbol (file-level only), got %d", len(symbols)) + } + + sym := symbols[0] + if sym.Name != "partial" { + t.Errorf("expected name 'partial', got %q", sym.Name) + } + if sym.Language != "html" { + t.Errorf("expected language 'html', got %q", sym.Language) + } + + // Check metadata + vars, ok := sym.Metadata["variables"].([]string) + if !ok || len(vars) == 0 { + t.Error("expected variables in metadata") + } + rangeVars, ok := sym.Metadata["ranges"].([]string) + if !ok || len(rangeVars) == 0 { + t.Error("expected ranges in metadata") + } +} + +func TestConvertToSymbols_Metadata(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{testdataPath("page.tmpl")}) + symbols := ConvertToSymbols(templates) + + // page.tmpl has 1 define ("content") + 1 file-level + if len(symbols) != 2 { + t.Fatalf("expected 2 symbols, got %d", len(symbols)) + } + + fileSym := symbols[1] // file-level is always last + includes, ok := fileSym.Metadata["includes"].([]string) + if !ok { + t.Fatal("expected includes in metadata") + } + found := false + for _, inc := range includes { + if inc == "base" { + found = true + } + } + if !found { + t.Errorf("expected include 'base' in metadata, got %v", includes) + } + + customFuncs, ok := fileSym.Metadata["custom_funcs"].([]string) + if !ok || len(customFuncs) == 0 { + t.Error("expected custom_funcs in metadata") + } +} diff --git a/pkg/parser/html/gotemplate/analyzer.go b/pkg/parser/html/gotemplate/analyzer.go new file mode 100644 index 0000000..7993f1a --- /dev/null +++ b/pkg/parser/html/gotemplate/analyzer.go @@ -0,0 +1,196 @@ +package gotemplate + +import ( + "bufio" + "os" + "regexp" + "strings" +) + +// Regex patterns for Go template directives. +var ( + reDefine = regexp.MustCompile(`\{\{-?\s*define\s+"([^"]+)"\s*-?\}\}`) + reBlock = regexp.MustCompile(`\{\{-?\s*block\s+"([^"]+)"\s*(\.[\w.]*)?`) + reTemplate = regexp.MustCompile(`\{\{-?\s*template\s+"([^"]+)"\s*(\.[\w.]*)?`) + reRange = regexp.MustCompile(`\{\{-?\s*range\s+(\.[\w.]+)`) + reIf = regexp.MustCompile(`\{\{-?\s*if\s+(.+?)\s*-?\}\}`) + reElse = regexp.MustCompile(`\{\{-?\s*else\s*-?\}\}`) + reWith = regexp.MustCompile(`\{\{-?\s*with\s+(\.[\w.]+)`) + reEnd = regexp.MustCompile(`\{\{-?\s*end\s*-?\}\}`) + reComment = regexp.MustCompile(`\{\{/\*.*?\*/\}\}`) + reVariable = regexp.MustCompile(`\{\{-?\s*(\.[\w.]+)\s*-?\}\}`) + // Custom funcs: {{ funcName ... }} where funcName is not a keyword. + reCustomFunc = regexp.MustCompile(`\{\{-?\s*([a-zA-Z]\w+)\s+`) + + // Go template keywords that are NOT custom functions. + keywords = map[string]bool{ + "if": true, "else": true, "end": true, "range": true, + "with": true, "template": true, "define": true, "block": true, + "nil": true, "not": true, "and": true, "or": true, + "print": true, "printf": true, "println": true, + "len": true, "index": true, "slice": true, "call": true, + "html": true, "js": true, "urlquery": true, + "eq": true, "ne": true, "lt": true, "le": true, "gt": true, "ge": true, + } +) + +// GoTemplateAnalyzer parses Go template files and extracts directives. +type GoTemplateAnalyzer struct{} + +// Analyze parses the given template files, extracting directives. +func (a *GoTemplateAnalyzer) Analyze(filePaths []string) []GoTemplate { + var templates []GoTemplate + for _, fp := range filePaths { + tpl, err := a.analyzeFile(fp) + if err != nil { + continue // skip unreadable files + } + templates = append(templates, tpl) + } + return templates +} + +// analyzeFile parses a single Go template file. +func (a *GoTemplateAnalyzer) analyzeFile(filePath string) (GoTemplate, error) { + file, err := os.Open(filePath) + if err != nil { + return GoTemplate{}, err + } + defer file.Close() + + tpl := GoTemplate{ + FilePath: filePath, + } + + // Track open blocks for EndLine matching. + type openBlock struct { + kind string // "define", "block", "range", "if", "with" + idx int // index in the corresponding slice + } + var stack []openBlock + + varSet := make(map[string]bool) + funcSet := make(map[string]bool) + + lineNum := 0 + scanner := bufio.NewScanner(file) + // Allow lines up to 1MB. + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + lineNum++ + line := scanner.Text() + + // Comments (can be multiline in theory, but we handle single-line here) + if matches := reComment.FindAllString(line, -1); len(matches) > 0 { + tpl.Comments = append(tpl.Comments, matches...) + } + + // {{ define "name" }} + if m := reDefine.FindStringSubmatch(line); m != nil { + tpl.Defines = append(tpl.Defines, DefineDirective{ + Name: m[1], + Line: lineNum, + }) + stack = append(stack, openBlock{kind: "define", idx: len(tpl.Defines) - 1}) + } + + // {{ block "name" pipeline }} + if m := reBlock.FindStringSubmatch(line); m != nil { + pipeline := strings.TrimSpace(m[2]) + tpl.Blocks = append(tpl.Blocks, BlockDirective{ + Name: m[1], + Pipeline: pipeline, + Line: lineNum, + }) + stack = append(stack, openBlock{kind: "block", idx: len(tpl.Blocks) - 1}) + } + + // {{ template "name" pipeline }} + if m := reTemplate.FindStringSubmatch(line); m != nil { + pipeline := strings.TrimSpace(m[2]) + tpl.TemplateIncludes = append(tpl.TemplateIncludes, TemplateInclude{ + Name: m[1], + Pipeline: pipeline, + Line: lineNum, + }) + } + + // {{ range .Variable }} + if m := reRange.FindStringSubmatch(line); m != nil { + tpl.Ranges = append(tpl.Ranges, RangeDirective{ + Variable: m[1], + Line: lineNum, + }) + stack = append(stack, openBlock{kind: "range", idx: len(tpl.Ranges) - 1}) + } + + // {{ if .Condition }} + if m := reIf.FindStringSubmatch(line); m != nil { + tpl.Conditionals = append(tpl.Conditionals, ConditionalDirective{ + Condition: strings.TrimSpace(m[1]), + Line: lineNum, + }) + stack = append(stack, openBlock{kind: "if", idx: len(tpl.Conditionals) - 1}) + } + + // {{ else }} + if reElse.MatchString(line) { + // Find the matching if on the stack + for i := len(stack) - 1; i >= 0; i-- { + if stack[i].kind == "if" { + tpl.Conditionals[stack[i].idx].HasElse = true + break + } + } + } + + // {{ with .Pipeline }} + if m := reWith.FindStringSubmatch(line); m != nil { + tpl.WithBlocks = append(tpl.WithBlocks, WithDirective{ + Pipeline: m[1], + Line: lineNum, + }) + stack = append(stack, openBlock{kind: "with", idx: len(tpl.WithBlocks) - 1}) + } + + // {{ end }} + if reEnd.MatchString(line) && len(stack) > 0 { + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + switch top.kind { + case "define": + tpl.Defines[top.idx].EndLine = lineNum + case "block": + tpl.Blocks[top.idx].EndLine = lineNum + case "range": + tpl.Ranges[top.idx].EndLine = lineNum + case "if": + tpl.Conditionals[top.idx].EndLine = lineNum + case "with": + tpl.WithBlocks[top.idx].EndLine = lineNum + } + } + + // Variables: {{ .Something }} — but not inside other directives we already captured + for _, m := range reVariable.FindAllStringSubmatch(line, -1) { + v := m[1] + if !varSet[v] { + varSet[v] = true + tpl.Variables = append(tpl.Variables, v) + } + } + + // Custom functions: {{ funcName arg }} where funcName is not a keyword + for _, m := range reCustomFunc.FindAllStringSubmatch(line, -1) { + funcName := m[1] + if !keywords[funcName] && !funcSet[funcName] { + funcSet[funcName] = true + tpl.CustomFuncs = append(tpl.CustomFuncs, funcName) + } + } + } + + tpl.TotalLines = lineNum + return tpl, nil +} diff --git a/pkg/parser/html/gotemplate/analyzer_test.go b/pkg/parser/html/gotemplate/analyzer_test.go new file mode 100644 index 0000000..448dd28 --- /dev/null +++ b/pkg/parser/html/gotemplate/analyzer_test.go @@ -0,0 +1,181 @@ +package gotemplate + +import ( + "path/filepath" + "testing" +) + +func testdataPath(name string) string { + return filepath.Join("testdata", name) +} + +func TestAnalyze_Layout(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{testdataPath("layout.html")}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + tpl := templates[0] + + // Defines + if len(tpl.Defines) != 1 { + t.Errorf("expected 1 define, got %d", len(tpl.Defines)) + } else if tpl.Defines[0].Name != "base" { + t.Errorf("expected define name 'base', got %q", tpl.Defines[0].Name) + } + + // Template includes + if len(tpl.TemplateIncludes) != 1 { + t.Errorf("expected 1 template include, got %d: %v", len(tpl.TemplateIncludes), tpl.TemplateIncludes) + } else if tpl.TemplateIncludes[0].Name != "nav" { + t.Errorf("expected template include 'nav', got %q", tpl.TemplateIncludes[0].Name) + } + + // Blocks + if len(tpl.Blocks) != 1 { + t.Errorf("expected 1 block, got %d: %v", len(tpl.Blocks), tpl.Blocks) + } else if tpl.Blocks[0].Name != "content" { + t.Errorf("expected block 'content', got %q", tpl.Blocks[0].Name) + } + + // Ranges + if len(tpl.Ranges) != 1 { + t.Errorf("expected 1 range, got %d: %v", len(tpl.Ranges), tpl.Ranges) + } else if tpl.Ranges[0].Variable != ".Items" { + t.Errorf("expected range variable '.Items', got %q", tpl.Ranges[0].Variable) + } + + // Conditionals + if len(tpl.Conditionals) != 1 { + t.Errorf("expected 1 conditional, got %d: %v", len(tpl.Conditionals), tpl.Conditionals) + } else { + cond := tpl.Conditionals[0] + if cond.Condition != ".IsAdmin" { + t.Errorf("expected condition '.IsAdmin', got %q", cond.Condition) + } + if !cond.HasElse { + t.Error("expected HasElse=true") + } + } + + // With + if len(tpl.WithBlocks) != 1 { + t.Errorf("expected 1 with block, got %d: %v", len(tpl.WithBlocks), tpl.WithBlocks) + } else if tpl.WithBlocks[0].Pipeline != ".Footer" { + t.Errorf("expected with pipeline '.Footer', got %q", tpl.WithBlocks[0].Pipeline) + } + + // Comments + if len(tpl.Comments) != 1 { + t.Errorf("expected 1 comment, got %d: %v", len(tpl.Comments), tpl.Comments) + } + + // Custom funcs + if len(tpl.CustomFuncs) == 0 { + t.Error("expected at least 1 custom func (formatDate)") + } else { + found := false + for _, f := range tpl.CustomFuncs { + if f == "formatDate" { + found = true + break + } + } + if !found { + t.Errorf("expected custom func 'formatDate', got %v", tpl.CustomFuncs) + } + } + + // TotalLines + if tpl.TotalLines == 0 { + t.Error("expected TotalLines > 0") + } +} + +func TestAnalyze_Page(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{testdataPath("page.tmpl")}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + tpl := templates[0] + + // Template includes ({{ template "base" . }}) + if len(tpl.TemplateIncludes) != 1 { + t.Errorf("expected 1 template include, got %d", len(tpl.TemplateIncludes)) + } else if tpl.TemplateIncludes[0].Name != "base" { + t.Errorf("expected include 'base', got %q", tpl.TemplateIncludes[0].Name) + } + + // Defines ({{ define "content" }}) + if len(tpl.Defines) != 1 { + t.Errorf("expected 1 define, got %d", len(tpl.Defines)) + } else if tpl.Defines[0].Name != "content" { + t.Errorf("expected define 'content', got %q", tpl.Defines[0].Name) + } + + // Ranges + if len(tpl.Ranges) != 1 { + t.Errorf("expected 1 range, got %d", len(tpl.Ranges)) + } else if tpl.Ranges[0].Variable != ".Posts" { + t.Errorf("expected range '.Posts', got %q", tpl.Ranges[0].Variable) + } + + // Custom funcs + found := false + for _, f := range tpl.CustomFuncs { + if f == "formatDate" { + found = true + break + } + } + if !found { + t.Errorf("expected custom func 'formatDate', got %v", tpl.CustomFuncs) + } +} + +func TestAnalyze_Partial(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{testdataPath("partial.gohtml")}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + tpl := templates[0] + + // Conditionals + if len(tpl.Conditionals) != 1 { + t.Errorf("expected 1 conditional, got %d", len(tpl.Conditionals)) + } + + // Ranges + if len(tpl.Ranges) != 1 { + t.Errorf("expected 1 range, got %d", len(tpl.Ranges)) + } else if tpl.Ranges[0].Variable != ".Widgets" { + t.Errorf("expected range '.Widgets', got %q", tpl.Ranges[0].Variable) + } +} + +func TestAnalyze_MultipleFiles(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{ + testdataPath("layout.html"), + testdataPath("page.tmpl"), + testdataPath("partial.gohtml"), + }) + + if len(templates) != 3 { + t.Fatalf("expected 3 templates, got %d", len(templates)) + } +} + +func TestAnalyze_NonexistentFile(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{"testdata/nonexistent.html"}) + + if len(templates) != 0 { + t.Errorf("expected 0 templates for nonexistent file, got %d", len(templates)) + } +} diff --git a/pkg/parser/html/gotemplate/testdata/layout.html b/pkg/parser/html/gotemplate/testdata/layout.html new file mode 100644 index 0000000..3a79f0b --- /dev/null +++ b/pkg/parser/html/gotemplate/testdata/layout.html @@ -0,0 +1,32 @@ +{{ define "base" }} + + + + {{ .Title }} + + + {{ template "nav" . }} + + {{ block "content" . }} +

Default content

+ {{ end }} + + {{ range .Items }} +
{{ .Name }}
+ {{ end }} + + {{ if .IsAdmin }} +
Admin panel
+ {{ else }} +
User panel
+ {{ end }} + + {{ with .Footer }} + + {{ end }} + + {{/* This is a comment */}} + {{ formatDate .CreatedAt }} + + +{{ end }} diff --git a/pkg/parser/html/gotemplate/testdata/page.tmpl b/pkg/parser/html/gotemplate/testdata/page.tmpl new file mode 100644 index 0000000..689e05d --- /dev/null +++ b/pkg/parser/html/gotemplate/testdata/page.tmpl @@ -0,0 +1,12 @@ +{{ template "base" . }} + +{{ define "content" }} +

{{ .PageTitle }}

+{{ range .Posts }} +
+

{{ .Title }}

+

{{ .Body | truncate 200 }}

+ +
+{{ end }} +{{ end }} diff --git a/pkg/parser/html/gotemplate/testdata/partial.gohtml b/pkg/parser/html/gotemplate/testdata/partial.gohtml new file mode 100644 index 0000000..39ab068 --- /dev/null +++ b/pkg/parser/html/gotemplate/testdata/partial.gohtml @@ -0,0 +1,7 @@ +{{ if .ShowSidebar }} + +{{ end }} diff --git a/pkg/parser/html/gotemplate/types.go b/pkg/parser/html/gotemplate/types.go new file mode 100644 index 0000000..738df0b --- /dev/null +++ b/pkg/parser/html/gotemplate/types.go @@ -0,0 +1,60 @@ +package gotemplate + +// GoTemplate represents a parsed Go template file with all its directives. +type GoTemplate struct { + FilePath string + TotalLines int + Defines []DefineDirective + Blocks []BlockDirective + TemplateIncludes []TemplateInclude + Ranges []RangeDirective + Conditionals []ConditionalDirective + WithBlocks []WithDirective + Variables []string + CustomFuncs []string + Comments []string +} + +// DefineDirective represents a {{ define "name" }} ... {{ end }} block. +type DefineDirective struct { + Name string + Line int + EndLine int +} + +// BlockDirective represents a {{ block "name" pipeline }} ... {{ end }} construct. +type BlockDirective struct { + Name string + Pipeline string // the pipeline after name, e.g. "." + Line int + EndLine int +} + +// TemplateInclude represents a {{ template "name" pipeline }} call. +type TemplateInclude struct { + Name string + Pipeline string // e.g. "." or ".Data" + Line int +} + +// RangeDirective represents a {{ range pipeline }} ... {{ end }} loop. +type RangeDirective struct { + Variable string // what is iterated, e.g. ".Items" + Line int + EndLine int +} + +// ConditionalDirective represents a {{ if pipeline }} ... {{ end }} conditional. +type ConditionalDirective struct { + Condition string + HasElse bool + Line int + EndLine int +} + +// WithDirective represents a {{ with pipeline }} ... {{ end }} scoping block. +type WithDirective struct { + Pipeline string + Line int + EndLine int +} From e65d51361c110ba5a113224391353dbba4f8c4c0 Mon Sep 17 00:00:00 2001 From: razvan Date: Thu, 19 Mar 2026 17:56:32 +0200 Subject: [PATCH 2/6] fix: uninstall now correctly parses V2 registry to delete per-workspace .ragcode dirs The cleanWorkspaceData function was treating registry.json as a flat map[string]interface{} and iterating over top-level keys (version, entries, candidates) instead of extracting workspace root paths from entries[].root. Added extractWorkspaceRoots() that properly handles V2 (struct with entries array), V1 (plain array), and legacy (flat map) registry formats. Added comprehensive unit tests covering all registry formats, edge cases, and an integration test for cleanWorkspaceData. --- internal/uninstall/uninstall.go | 72 +++++++++++- internal/uninstall/uninstall_test.go | 162 +++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 internal/uninstall/uninstall_test.go diff --git a/internal/uninstall/uninstall.go b/internal/uninstall/uninstall.go index d8510f0..5c96eb8 100644 --- a/internal/uninstall/uninstall.go +++ b/internal/uninstall/uninstall.go @@ -314,15 +314,15 @@ func cleanWorkspaceData(home string) { return } - var registry map[string]interface{} - if err := json.Unmarshal(data, ®istry); err != nil { - warnMsg("Could not parse registry: " + err.Error()) + roots := extractWorkspaceRoots(data) + if len(roots) == 0 { + warnMsg("Could not extract workspace paths from registry, scanning common directories...") scanAndCleanRagcodeDirs(home) return } cleaned := 0 - for wsPath := range registry { + for _, wsPath := range roots { ragDir := filepath.Join(wsPath, ".ragcode") if _, err := os.Stat(ragDir); err == nil { if err := os.RemoveAll(ragDir); err != nil { @@ -335,10 +335,72 @@ func cleanWorkspaceData(home string) { } if cleaned == 0 { - logMsg("No per-workspace .ragcode/ directories found") + logMsg("No per-workspace .ragcode/ directories found in registry entries") } } +// extractWorkspaceRoots tries to parse the registry in all known formats +// and returns all workspace root paths found. +func extractWorkspaceRoots(data []byte) []string { + // V2 format: {"version":"v2", "entries":[{"root":"/path",...}], ...} + var v2Store struct { + Version string `json:"version"` + Entries []struct { + Root string `json:"root"` + } `json:"entries"` + } + if err := json.Unmarshal(data, &v2Store); err == nil && v2Store.Version != "" && len(v2Store.Entries) > 0 { + roots := make([]string, 0, len(v2Store.Entries)) + for _, e := range v2Store.Entries { + if e.Root != "" { + roots = append(roots, e.Root) + } + } + if len(roots) > 0 { + logMsg(fmt.Sprintf("Found %d workspace(s) in V2 registry", len(roots))) + return roots + } + } + + // V1 format: [{"root":"/path",...},...] + var v1Entries []struct { + Root string `json:"root"` + } + if err := json.Unmarshal(data, &v1Entries); err == nil && len(v1Entries) > 0 { + roots := make([]string, 0, len(v1Entries)) + for _, e := range v1Entries { + if e.Root != "" { + roots = append(roots, e.Root) + } + } + if len(roots) > 0 { + logMsg(fmt.Sprintf("Found %d workspace(s) in V1 registry", len(roots))) + return roots + } + } + + // Legacy flat map format: {"/path/to/ws": {...}, ...} + var flatMap map[string]interface{} + if err := json.Unmarshal(data, &flatMap); err == nil { + roots := make([]string, 0, len(flatMap)) + for key := range flatMap { + // Skip known non-path keys from V2 format + if key == "version" || key == "entries" || key == "candidates" { + continue + } + if key != "" { + roots = append(roots, key) + } + } + if len(roots) > 0 { + logMsg(fmt.Sprintf("Found %d workspace(s) in legacy registry", len(roots))) + return roots + } + } + + return nil +} + func scanAndCleanRagcodeDirs(home string) { searchRoots := []string{ filepath.Join(home, "Projects"), diff --git a/internal/uninstall/uninstall_test.go b/internal/uninstall/uninstall_test.go new file mode 100644 index 0000000..2593ced --- /dev/null +++ b/internal/uninstall/uninstall_test.go @@ -0,0 +1,162 @@ +package uninstall + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" +) + +func TestExtractWorkspaceRoots_V2(t *testing.T) { + data := []byte(`{ + "version": "v2", + "entries": [ + {"root": "/home/user/project-a", "id": "abc123"}, + {"root": "/home/user/project-b", "id": "def456"}, + {"root": "/opt/workspace/app", "id": "ghi789"} + ], + "candidates": [] + }`) + + roots := extractWorkspaceRoots(data) + if len(roots) != 3 { + t.Fatalf("expected 3 roots, got %d: %v", len(roots), roots) + } + + sort.Strings(roots) + expected := []string{"/home/user/project-a", "/home/user/project-b", "/opt/workspace/app"} + sort.Strings(expected) + + for i, r := range roots { + if r != expected[i] { + t.Errorf("root[%d] = %q, want %q", i, r, expected[i]) + } + } +} + +func TestExtractWorkspaceRoots_V1(t *testing.T) { + data := []byte(`[ + {"root": "/home/user/proj1", "id": "a1"}, + {"root": "/home/user/proj2", "id": "b2"} + ]`) + + roots := extractWorkspaceRoots(data) + if len(roots) != 2 { + t.Fatalf("expected 2 roots, got %d: %v", len(roots), roots) + } + + sort.Strings(roots) + if roots[0] != "/home/user/proj1" || roots[1] != "/home/user/proj2" { + t.Errorf("unexpected roots: %v", roots) + } +} + +func TestExtractWorkspaceRoots_LegacyFlatMap(t *testing.T) { + data := []byte(`{ + "/home/user/old-project": {"name": "old"}, + "/var/www/site": {"name": "site"} + }`) + + roots := extractWorkspaceRoots(data) + if len(roots) != 2 { + t.Fatalf("expected 2 roots, got %d: %v", len(roots), roots) + } + + sort.Strings(roots) + if roots[0] != "/home/user/old-project" || roots[1] != "/var/www/site" { + t.Errorf("unexpected roots: %v", roots) + } +} + +func TestExtractWorkspaceRoots_EmptyV2(t *testing.T) { + data := []byte(`{"version": "v2", "entries": []}`) + + roots := extractWorkspaceRoots(data) + if roots != nil { + t.Errorf("expected nil for empty V2, got %v", roots) + } +} + +func TestExtractWorkspaceRoots_InvalidJSON(t *testing.T) { + data := []byte(`{not valid json at all`) + + roots := extractWorkspaceRoots(data) + if roots != nil { + t.Errorf("expected nil for invalid JSON, got %v", roots) + } +} + +func TestExtractWorkspaceRoots_V2SkipsEmptyRoots(t *testing.T) { + data := []byte(`{ + "version": "v2", + "entries": [ + {"root": "/valid/path", "id": "x"}, + {"root": "", "id": "y"}, + {"root": "/another", "id": "z"} + ] + }`) + + roots := extractWorkspaceRoots(data) + if len(roots) != 2 { + t.Fatalf("expected 2 roots (skipping empty), got %d: %v", len(roots), roots) + } +} + +func TestCleanWorkspaceData_WithV2Registry(t *testing.T) { + // Create a temp "home" directory + home := t.TempDir() + + // Create fake .ragcode install dir with registry + installDir := filepath.Join(home, ".ragcode") + if err := os.MkdirAll(installDir, 0755); err != nil { + t.Fatal(err) + } + + // Create two fake project directories with .ragcode inside + proj1 := filepath.Join(home, "projects", "app1") + proj2 := filepath.Join(home, "projects", "app2") + proj3 := filepath.Join(home, "projects", "app3") // not in registry + + for _, p := range []string{proj1, proj2, proj3} { + ragDir := filepath.Join(p, ".ragcode") + if err := os.MkdirAll(ragDir, 0755); err != nil { + t.Fatal(err) + } + // Create a file inside to verify full removal + if err := os.WriteFile(filepath.Join(ragDir, "state.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + } + + // Write V2 registry with proj1 and proj2 (NOT proj3) + registry := map[string]interface{}{ + "version": "v2", + "entries": []map[string]string{ + {"root": proj1, "id": "aaa"}, + {"root": proj2, "id": "bbb"}, + }, + } + regData, _ := json.MarshalIndent(registry, "", " ") + if err := os.WriteFile(filepath.Join(installDir, "registry.json"), regData, 0644); err != nil { + t.Fatal(err) + } + + // Run the function + cleanWorkspaceData(home) + + // proj1/.ragcode should be gone + if _, err := os.Stat(filepath.Join(proj1, ".ragcode")); !os.IsNotExist(err) { + t.Errorf("proj1/.ragcode should have been removed") + } + + // proj2/.ragcode should be gone + if _, err := os.Stat(filepath.Join(proj2, ".ragcode")); !os.IsNotExist(err) { + t.Errorf("proj2/.ragcode should have been removed") + } + + // proj3/.ragcode should still exist (not in registry) + if _, err := os.Stat(filepath.Join(proj3, ".ragcode")); os.IsNotExist(err) { + t.Errorf("proj3/.ragcode should NOT have been removed (not in registry)") + } +} From 758d1848d859d49117a627a9cbf1d7f0d209a01c Mon Sep 17 00:00:00 2001 From: razvan Date: Thu, 19 Mar 2026 18:26:26 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(gotemplate):=20address=20PR=20#47=20rev?= =?UTF-8?q?iew=20+=20Go=E2=86=94Template=20AST=20linking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scanner.Err() check to prevent silent data truncation - Tighten regex patterns (reBlock/reTemplate/reRange --- pkg/parser/go/analyzer.go | 59 ++++++++++++--- pkg/parser/go/relations_test.go | 54 +++++++++++++ pkg/parser/go/types.go | 1 + pkg/parser/html/analyzer.go | 41 ++++++---- pkg/parser/html/gotemplate/adapter.go | 11 ++- pkg/parser/html/gotemplate/analyzer.go | 39 ++++++++-- pkg/parser/html/gotemplate/analyzer_test.go | 75 +++++++++++++++++++ .../html/gotemplate/testdata/elseif.tmpl | 23 ++++++ 8 files changed, 268 insertions(+), 35 deletions(-) create mode 100644 pkg/parser/html/gotemplate/testdata/elseif.tmpl diff --git a/pkg/parser/go/analyzer.go b/pkg/parser/go/analyzer.go index 5fe3c93..569a3da 100644 --- a/pkg/parser/go/analyzer.go +++ b/pkg/parser/go/analyzer.go @@ -231,7 +231,7 @@ func (ca *CodeAnalyzer) analyzeFunctionDecl(fset *token.FileSet, fn *doc.Func, a info.Parameters = ca.extractParameters(fn.Decl.Type.Params) info.Returns = ca.extractReturns(fn.Decl.Type.Results) } - info.Calls = ca.extractCallsFromAST(astBody) + info.Calls, info.TemplateFiles = ca.extractCallsFromAST(astBody) } else if fn.Decl != nil { // Fallback to doc.Func Decl (won't have Body) // Extract position information @@ -761,6 +761,24 @@ func convertPackageInfoToChunks(pi *PackageInfo) []CodeChunk { Type: pkgParser.RelCalls, }) } + // Template file dependencies: template.ParseFiles("layout.html") → RelDependency + for _, tplFile := range fn.TemplateFiles { + rels = append(rels, pkgParser.Relation{ + TargetName: tplFile, + Type: pkgParser.RelDependency, + }) + } + + metadata := map[string]any{ + "receiver": fn.Receiver, + "is_method": fn.IsMethod, + "params": fn.Parameters, + "returns": fn.Returns, + "examples": fn.Examples, + } + if len(fn.TemplateFiles) > 0 { + metadata["template_files"] = fn.TemplateFiles + } out = append(out, CodeChunk{ Type: kind, @@ -774,13 +792,7 @@ func convertPackageInfoToChunks(pi *PackageInfo) []CodeChunk { Docstring: fn.Description, Code: fn.Code, Relations: rels, - Metadata: map[string]any{ - "receiver": fn.Receiver, - "is_method": fn.IsMethod, - "params": fn.Parameters, - "returns": fn.Returns, - "examples": fn.Examples, - }, + Metadata: metadata, }) } @@ -925,29 +937,52 @@ func (ca *CodeAnalyzer) extractCodeFromFile(filePath string, startLine, endLine return strings.Join(lines, "\n"), nil } -func (ca *CodeAnalyzer) extractCallsFromAST(body *ast.BlockStmt) []string { +func (ca *CodeAnalyzer) extractCallsFromAST(body *ast.BlockStmt) ([]string, []string) { if body == nil { - return nil + return nil, nil } var calls []string + var templateFiles []string seen := make(map[string]bool) + seenTpl := make(map[string]bool) + + // Template-related function names that receive file paths as string arguments. + templateFuncs := map[string]bool{ + "ParseFiles": true, "ParseGlob": true, + } ast.Inspect(body, func(n ast.Node) bool { if call, ok := n.(*ast.CallExpr); ok { var name string + var sel string // selector part (e.g. "template" in template.ParseFiles) switch fun := call.Fun.(type) { case *ast.Ident: name = fun.Name case *ast.SelectorExpr: x := ca.typeToString(fun.X) - name = fmt.Sprintf("%s.%s", x, fun.Sel.Name) + sel = fun.Sel.Name + name = fmt.Sprintf("%s.%s", x, sel) } if name != "" && !seen[name] { calls = append(calls, name) seen[name] = true } + + // Extract template file paths from template.ParseFiles("a.html", "b.html") + // and similar calls (works on any receiver: t.ParseFiles, tpl.ParseFiles, etc.) + if templateFuncs[sel] { + for _, arg := range call.Args { + if lit, ok := arg.(*ast.BasicLit); ok && lit.Kind == token.STRING { + path := strings.Trim(lit.Value, "\"`") + if path != "" && !seenTpl[path] { + seenTpl[path] = true + templateFiles = append(templateFiles, path) + } + } + } + } } return true }) - return calls + return calls, templateFiles } diff --git a/pkg/parser/go/relations_test.go b/pkg/parser/go/relations_test.go index 517e4de..c6fa3e3 100644 --- a/pkg/parser/go/relations_test.go +++ b/pkg/parser/go/relations_test.go @@ -100,3 +100,57 @@ func TestGoRelations_TypesAreCanonical(t *testing.T) { } } } + +const goTemplateUsageCode = `package web + +import "html/template" + +func RenderPage(w io.Writer, data any) error { + t, err := template.ParseFiles("templates/layout.html", "templates/header.html") + if err != nil { + return err + } + return t.Execute(w, data) +} + +func RenderDashboard(w io.Writer, data any) error { + t := template.Must(template.ParseGlob("templates/dashboard/*.tmpl")) + return t.Execute(w, data) +} +` + +func TestGoRelations_TemplateFileDependencies(t *testing.T) { + tmpDir := t.TempDir() + f := filepath.Join(tmpDir, "handler.go") + require.NoError(t, os.WriteFile(f, []byte(goTemplateUsageCode), 0644)) + + ca := NewCodeAnalyzer() + res, err := ca.Analyze(context.Background(), tmpDir) + require.NoError(t, err) + + renderPage := findGoSymbol(res.Symbols, "RenderPage") + require.NotNil(t, renderPage, "RenderPage function symbol must exist") + + // Should have dependency relations to template file paths + assert.True(t, hasGoRelation(renderPage.Relations, "templates/layout.html", pkgParser.RelDependency), + "RenderPage should have dependency→templates/layout.html; got %v", renderPage.Relations) + assert.True(t, hasGoRelation(renderPage.Relations, "templates/header.html", pkgParser.RelDependency), + "RenderPage should have dependency→templates/header.html; got %v", renderPage.Relations) + + // Should have template_files in metadata + if tplFiles, ok := renderPage.Metadata["template_files"]; ok { + files, ok := tplFiles.([]string) + assert.True(t, ok, "template_files metadata should be []string") + assert.Contains(t, files, "templates/layout.html") + assert.Contains(t, files, "templates/header.html") + } else { + t.Error("expected template_files metadata on RenderPage") + } + + // RenderDashboard: ParseGlob with glob pattern + renderDash := findGoSymbol(res.Symbols, "RenderDashboard") + require.NotNil(t, renderDash, "RenderDashboard function symbol must exist") + + assert.True(t, hasGoRelation(renderDash.Relations, "templates/dashboard/*.tmpl", pkgParser.RelDependency), + "RenderDashboard should have dependency→templates/dashboard/*.tmpl; got %v", renderDash.Relations) +} diff --git a/pkg/parser/go/types.go b/pkg/parser/go/types.go index 7582208..fc60721 100644 --- a/pkg/parser/go/types.go +++ b/pkg/parser/go/types.go @@ -51,6 +51,7 @@ type FunctionInfo struct { EndLine int `json:"end_line,omitempty"` Code string `json:"code,omitempty"` Calls []string `json:"calls,omitempty"` + TemplateFiles []string `json:"template_files,omitempty"` // Go template file paths from template.ParseFiles() etc. } // TypeInfo describes a type declaration (struct, interface, alias, etc.) diff --git a/pkg/parser/html/analyzer.go b/pkg/parser/html/analyzer.go index 6f559e5..3b9f992 100644 --- a/pkg/parser/html/analyzer.go +++ b/pkg/parser/html/analyzer.go @@ -53,24 +53,26 @@ func (a *Analyzer) CanHandle(filePath string) bool { func (a *Analyzer) Analyze(ctx context.Context, path string) (*pkgParser.Result, error) { var symbols []pkgParser.Symbol - // For single files: detect Go template syntax and run GoTemplate analysis + // Detect Go template syntax and run GoTemplate analysis info, err := os.Stat(path) if err != nil { return nil, err } if !info.IsDir() { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - // If Go template syntax detected, run Go template analysis first - if bytes.Contains(data, []byte("{{")) { - goTplAnalyzer := &gotemplate.GoTemplateAnalyzer{} - templates := goTplAnalyzer.Analyze([]string{path}) - symbols = append(symbols, gotemplate.ConvertToSymbols(templates)...) - } + // Single file: check for Go template syntax + symbols = append(symbols, a.analyzeGoTemplates(path)...) + } else { + // Directory: walk and check each HTML file for Go template syntax + filepath.WalkDir(path, func(fp string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if a.ca.isHTMLFile(d.Name()) { + symbols = append(symbols, a.analyzeGoTemplates(fp)...) + } + return nil + }) } // 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, }, nil } - - +// analyzeGoTemplates checks a single file for Go template syntax and returns symbols. +func (a *Analyzer) analyzeGoTemplates(filePath string) []pkgParser.Symbol { + data, err := os.ReadFile(filePath) + if err != nil { + return nil + } + if !bytes.Contains(data, []byte("{{")) { + return nil + } + goTplAnalyzer := &gotemplate.GoTemplateAnalyzer{} + templates := goTplAnalyzer.Analyze([]string{filePath}) + return gotemplate.ConvertToSymbols(templates) +} // CodeAnalyzer handles the heavy lifting of HTML analysis. type CodeAnalyzer struct{} diff --git a/pkg/parser/html/gotemplate/adapter.go b/pkg/parser/html/gotemplate/adapter.go index fe5ebcd..e0c2852 100644 --- a/pkg/parser/html/gotemplate/adapter.go +++ b/pkg/parser/html/gotemplate/adapter.go @@ -9,7 +9,9 @@ import ( ) // ConvertToSymbols converts parsed GoTemplate results to parser.Symbol entries -// with structural relations (dependency for {{ template }}, inheritance-like for {{ block }}). +// with structural relations: +// - RelDependency for {{ template "name" }} includes +// - RelInheritance for {{ block "name" }} (defines overridable default content) func ConvertToSymbols(templates []GoTemplate) []pkgParser.Symbol { var symbols []pkgParser.Symbol @@ -84,6 +86,13 @@ func buildFileSymbol(tpl GoTemplate, nameNoExt string) pkgParser.Symbol { Type: pkgParser.RelDependency, }) } + // Build relations: blocks → inheritance (overridable default content) + for _, blk := range tpl.Blocks { + relations = append(relations, pkgParser.Relation{ + TargetName: blk.Name, + Type: pkgParser.RelInheritance, + }) + } endLine := tpl.TotalLines if endLine < 1 { diff --git a/pkg/parser/html/gotemplate/analyzer.go b/pkg/parser/html/gotemplate/analyzer.go index 7993f1a..91c3cad 100644 --- a/pkg/parser/html/gotemplate/analyzer.go +++ b/pkg/parser/html/gotemplate/analyzer.go @@ -3,19 +3,23 @@ package gotemplate import ( "bufio" "os" + "path/filepath" "regexp" "strings" + + "github.com/doITmagic/rag-code-mcp/internal/logger" ) // Regex patterns for Go template directives. var ( reDefine = regexp.MustCompile(`\{\{-?\s*define\s+"([^"]+)"\s*-?\}\}`) - reBlock = regexp.MustCompile(`\{\{-?\s*block\s+"([^"]+)"\s*(\.[\w.]*)?`) - reTemplate = regexp.MustCompile(`\{\{-?\s*template\s+"([^"]+)"\s*(\.[\w.]*)?`) - reRange = regexp.MustCompile(`\{\{-?\s*range\s+(\.[\w.]+)`) + reBlock = regexp.MustCompile(`\{\{-?\s*block\s+"([^"]+)"\s*(\.[\w.]*)?\s*-?\}\}`) + reTemplate = regexp.MustCompile(`\{\{-?\s*template\s+"([^"]+)"\s*(\.[\w.]*)?\s*-?\}\}`) + reRange = regexp.MustCompile(`\{\{-?\s*range\s+(\.[\w.]+)\s*-?\}\}`) reIf = regexp.MustCompile(`\{\{-?\s*if\s+(.+?)\s*-?\}\}`) + reElseIf = regexp.MustCompile(`\{\{-?\s*else\s+if\s+(.+?)\s*-?\}\}`) reElse = regexp.MustCompile(`\{\{-?\s*else\s*-?\}\}`) - reWith = regexp.MustCompile(`\{\{-?\s*with\s+(\.[\w.]+)`) + reWith = regexp.MustCompile(`\{\{-?\s*with\s+(\.[\w.]+)\s*-?\}\}`) reEnd = regexp.MustCompile(`\{\{-?\s*end\s*-?\}\}`) reComment = regexp.MustCompile(`\{\{/\*.*?\*/\}\}`) reVariable = regexp.MustCompile(`\{\{-?\s*(\.[\w.]+)\s*-?\}\}`) @@ -43,7 +47,8 @@ func (a *GoTemplateAnalyzer) Analyze(filePaths []string) []GoTemplate { for _, fp := range filePaths { tpl, err := a.analyzeFile(fp) if err != nil { - continue // skip unreadable files + logger.Instance.Debug("[GOTEMPLATE] skip %s: %v", filepath.Base(fp), err) + continue } templates = append(templates, tpl) } @@ -134,9 +139,23 @@ func (a *GoTemplateAnalyzer) analyzeFile(filePath string) (GoTemplate, error) { stack = append(stack, openBlock{kind: "if", idx: len(tpl.Conditionals) - 1}) } - // {{ else }} - if reElse.MatchString(line) { - // Find the matching if on the stack + // {{ else if .Cond }} — must check BEFORE bare {{ else }} + if m := reElseIf.FindStringSubmatch(line); m != nil { + // Mark HasElse on the current if + for i := len(stack) - 1; i >= 0; i-- { + if stack[i].kind == "if" { + tpl.Conditionals[stack[i].idx].HasElse = true + break + } + } + // Push a new conditional for the else-if branch + tpl.Conditionals = append(tpl.Conditionals, ConditionalDirective{ + Condition: strings.TrimSpace(m[1]), + Line: lineNum, + }) + stack = append(stack, openBlock{kind: "if", idx: len(tpl.Conditionals) - 1}) + } else if reElse.MatchString(line) { + // {{ else }} — bare else without condition for i := len(stack) - 1; i >= 0; i-- { if stack[i].kind == "if" { tpl.Conditionals[stack[i].idx].HasElse = true @@ -191,6 +210,10 @@ func (a *GoTemplateAnalyzer) analyzeFile(filePath string) (GoTemplate, error) { } } + if err := scanner.Err(); err != nil { + return GoTemplate{}, err + } + tpl.TotalLines = lineNum return tpl, nil } diff --git a/pkg/parser/html/gotemplate/analyzer_test.go b/pkg/parser/html/gotemplate/analyzer_test.go index 448dd28..b52eb4d 100644 --- a/pkg/parser/html/gotemplate/analyzer_test.go +++ b/pkg/parser/html/gotemplate/analyzer_test.go @@ -179,3 +179,78 @@ func TestAnalyze_NonexistentFile(t *testing.T) { t.Errorf("expected 0 templates for nonexistent file, got %d", len(templates)) } } + +func TestAnalyze_ElseIf(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{testdataPath("elseif.tmpl")}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + tpl := templates[0] + + // Should have 3 conditionals: if .IsAdmin, else if .IsModerator, else if .IsEditor + if len(tpl.Conditionals) < 3 { + t.Errorf("expected at least 3 conditionals (if + 2 else-if), got %d: %v", + len(tpl.Conditionals), tpl.Conditionals) + } + + // First conditional (.IsAdmin) should have HasElse=true + if len(tpl.Conditionals) > 0 { + if tpl.Conditionals[0].Condition != ".IsAdmin" { + t.Errorf("expected first condition '.IsAdmin', got %q", tpl.Conditionals[0].Condition) + } + if !tpl.Conditionals[0].HasElse { + t.Error("expected first conditional HasElse=true (has else-if branch)") + } + } + + // Second conditional (.IsModerator) from else-if + if len(tpl.Conditionals) > 1 { + if tpl.Conditionals[1].Condition != ".IsModerator" { + t.Errorf("expected second condition '.IsModerator', got %q", tpl.Conditionals[1].Condition) + } + } + + // Third conditional (.IsEditor) from else-if + if len(tpl.Conditionals) > 2 { + if tpl.Conditionals[2].Condition != ".IsEditor" { + t.Errorf("expected third condition '.IsEditor', got %q", tpl.Conditionals[2].Condition) + } + } + + // Defines + if len(tpl.Defines) != 1 { + t.Errorf("expected 1 define, got %d", len(tpl.Defines)) + } else if tpl.Defines[0].Name != "dashboard" { + t.Errorf("expected define 'dashboard', got %q", tpl.Defines[0].Name) + } +} + +func TestAnalyze_BlockRelations(t *testing.T) { + ba := &GoTemplateAnalyzer{} + templates := ba.Analyze([]string{testdataPath("layout.html")}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + + // Convert and check for RelInheritance on blocks + symbols := ConvertToSymbols(templates) + if len(symbols) == 0 { + t.Fatal("expected at least 1 symbol") + } + + // File-level symbol should have RelInheritance for block "content" + var hasInheritance bool + for _, sym := range symbols { + for _, rel := range sym.Relations { + if rel.Type == "inheritance" && rel.TargetName == "content" { + hasInheritance = true + } + } + } + if !hasInheritance { + t.Error("expected RelInheritance for block 'content' in symbols") + } +} diff --git a/pkg/parser/html/gotemplate/testdata/elseif.tmpl b/pkg/parser/html/gotemplate/testdata/elseif.tmpl new file mode 100644 index 0000000..ba41b45 --- /dev/null +++ b/pkg/parser/html/gotemplate/testdata/elseif.tmpl @@ -0,0 +1,23 @@ +{{ define "dashboard" }} +
+{{ if .IsAdmin }} +

Admin Panel

+{{ else if .IsModerator }} +

Moderator View

+{{ else if .IsEditor }} +

Editor View

+{{ else }} +

User View

+{{ end }} + +{{ range .Items }} + {{ .Name }} +{{ end }} + +{{ with .Profile }} +

{{ .Bio }}

+{{ end }} + +{{/* Dashboard template with else-if chains */}} +
+{{ end }} From ff45478ae4fb5cfa65e214c8a64662ba086d4d2c Mon Sep 17 00:00:00 2001 From: razvan Date: Thu, 19 Mar 2026 18:28:42 +0200 Subject: [PATCH 4/6] fix(lint): check filepath.WalkDir return value (errcheck) --- pkg/parser/html/analyzer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/parser/html/analyzer.go b/pkg/parser/html/analyzer.go index 3b9f992..e840a5f 100644 --- a/pkg/parser/html/analyzer.go +++ b/pkg/parser/html/analyzer.go @@ -64,7 +64,7 @@ func (a *Analyzer) Analyze(ctx context.Context, path string) (*pkgParser.Result, symbols = append(symbols, a.analyzeGoTemplates(path)...) } else { // Directory: walk and check each HTML file for Go template syntax - filepath.WalkDir(path, func(fp string, d fs.DirEntry, err error) error { + _ = filepath.WalkDir(path, func(fp string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } From 41e3ca17c3c23c7513cb0a9ee4754738d767dc6f Mon Sep 17 00:00:00 2001 From: razvan Date: Thu, 19 Mar 2026 18:37:43 +0200 Subject: [PATCH 5/6] fix(test): skip updater tests on GitHub API 403 rate limit --- internal/updater/updater_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 03b781e..c914db1 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" ) @@ -73,6 +74,9 @@ func TestFetchRemoteStableModel(t *testing.T) { model, err := fetchRemoteStableModel(ctx) if err != nil { + if strings.Contains(err.Error(), "status 403") { + t.Skip("Skipping: GitHub API rate limit (403)") + } t.Fatalf("fetchRemoteStableModel failed: %v", err) } @@ -96,6 +100,10 @@ func TestCheckForUpdates(t *testing.T) { // Testing with a very old version to trigger the update logic info, err := CheckForUpdates(ctx, "0.0.1", true) if err != nil { + // GitHub API rate-limits unauthenticated requests (403) — skip in CI + if strings.Contains(err.Error(), "status 403") { + t.Skip("Skipping: GitHub API rate limit (403) — expected in CI without auth token") + } t.Fatalf("CheckForUpdates failed: %v", err) } From b97131e7e31c899a624337e835b53180d87b9756 Mon Sep 17 00:00:00 2001 From: razvan Date: Fri, 20 Mar 2026 09:31:38 +0200 Subject: [PATCH 6/6] fix(gotemplate): correct else-if stack handling and review fixes - Fix critical bug: else-if was pushing new stack entries causing EndLine corruption (define blocks never got EndLine assigned) - Log WalkDir errors instead of silently ignoring them - Fix misleading comment on sel variable in Go analyzer --- pkg/parser/go/analyzer.go | 2 +- pkg/parser/html/analyzer.go | 7 +++++-- pkg/parser/html/gotemplate/analyzer.go | 7 +++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/parser/go/analyzer.go b/pkg/parser/go/analyzer.go index 569a3da..2daf8a5 100644 --- a/pkg/parser/go/analyzer.go +++ b/pkg/parser/go/analyzer.go @@ -954,7 +954,7 @@ func (ca *CodeAnalyzer) extractCallsFromAST(body *ast.BlockStmt) ([]string, []st ast.Inspect(body, func(n ast.Node) bool { if call, ok := n.(*ast.CallExpr); ok { var name string - var sel string // selector part (e.g. "template" in template.ParseFiles) + var sel string // selector/method part (e.g. "ParseFiles" in template.ParseFiles) switch fun := call.Fun.(type) { case *ast.Ident: name = fun.Name diff --git a/pkg/parser/html/analyzer.go b/pkg/parser/html/analyzer.go index e840a5f..f016c54 100644 --- a/pkg/parser/html/analyzer.go +++ b/pkg/parser/html/analyzer.go @@ -64,7 +64,7 @@ func (a *Analyzer) Analyze(ctx context.Context, path string) (*pkgParser.Result, symbols = append(symbols, a.analyzeGoTemplates(path)...) } else { // Directory: walk and check each HTML file for Go template syntax - _ = filepath.WalkDir(path, func(fp string, d fs.DirEntry, err error) error { + if walkErr := filepath.WalkDir(path, func(fp string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } @@ -72,7 +72,10 @@ func (a *Analyzer) Analyze(ctx context.Context, path string) (*pkgParser.Result, symbols = append(symbols, a.analyzeGoTemplates(fp)...) } return nil - }) + }); walkErr != nil { + // Log but don't fail — HTML DOM analysis may still succeed + fmt.Fprintf(os.Stderr, "[HTML] walk error for Go template detection: %v\n", walkErr) + } } // Always run HTML DOM analysis too (Go templates contain HTML) diff --git a/pkg/parser/html/gotemplate/analyzer.go b/pkg/parser/html/gotemplate/analyzer.go index 91c3cad..e9afe68 100644 --- a/pkg/parser/html/gotemplate/analyzer.go +++ b/pkg/parser/html/gotemplate/analyzer.go @@ -140,6 +140,8 @@ func (a *GoTemplateAnalyzer) analyzeFile(filePath string) (GoTemplate, error) { } // {{ else if .Cond }} — must check BEFORE bare {{ else }} + // NOTE: else-if does NOT push a new stack entry because Go templates + // use a single {{ end }} for the entire if/else-if/else chain. if m := reElseIf.FindStringSubmatch(line); m != nil { // Mark HasElse on the current if for i := len(stack) - 1; i >= 0; i-- { @@ -148,12 +150,13 @@ func (a *GoTemplateAnalyzer) analyzeFile(filePath string) (GoTemplate, error) { break } } - // Push a new conditional for the else-if branch + // Record the else-if branch as a conditional (for metadata) + // but do NOT push onto stack — no extra {{ end }} expected. tpl.Conditionals = append(tpl.Conditionals, ConditionalDirective{ Condition: strings.TrimSpace(m[1]), Line: lineNum, + HasElse: true, // part of an else-if chain }) - stack = append(stack, openBlock{kind: "if", idx: len(tpl.Conditionals) - 1}) } else if reElse.MatchString(line) { // {{ else }} — bare else without condition for i := len(stack) - 1; i >= 0; i-- {