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)") + } +} 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) } diff --git a/pkg/parser/go/analyzer.go b/pkg/parser/go/analyzer.go index 5fe3c93..2daf8a5 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/method part (e.g. "ParseFiles" 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 a9b5794..f016c54 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,61 @@ 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 + + // Detect Go template syntax and run GoTemplate analysis + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !info.IsDir() { + // 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 + if walkErr := 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 + }); 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) 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, @@ -72,8 +110,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{} @@ -252,7 +301,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..e0c2852 --- /dev/null +++ b/pkg/parser/html/gotemplate/adapter.go @@ -0,0 +1,190 @@ +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: +// - RelDependency for {{ template "name" }} includes +// - RelInheritance for {{ block "name" }} (defines overridable default content) +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, + }) + } + // 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 { + 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..e9afe68 --- /dev/null +++ b/pkg/parser/html/gotemplate/analyzer.go @@ -0,0 +1,222 @@ +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.]*)?\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.]+)\s*-?\}\}`) + 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 { + logger.Instance.Debug("[GOTEMPLATE] skip %s: %v", filepath.Base(fp), err) + continue + } + 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 .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-- { + if stack[i].kind == "if" { + tpl.Conditionals[stack[i].idx].HasElse = true + break + } + } + // 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 + }) + } 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 + 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) + } + } + } + + 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 new file mode 100644 index 0000000..b52eb4d --- /dev/null +++ b/pkg/parser/html/gotemplate/analyzer_test.go @@ -0,0 +1,256 @@ +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)) + } +} + +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 }} 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 +}