-
Notifications
You must be signed in to change notification settings - Fork 4
feat(html): add Go template parser with structural analysis #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
cc2d09e
e65d513
758d184
ff45478
41e3ca1
b97131e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Comment on lines
956
to
+974
|
||
| 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
V2 registry detection accepts any non-empty
versionvalue (v2Store.Version != ""). If a future registry format includes a different version string but still has anentriesarray, this will be treated as V2 and could cause unintended deletions. Consider checking for the expected value (e.g.,== "v2") before trusting the parsed roots.