diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0eb1551..2e9f5ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.24' cache: true - name: Run GoReleaser diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eef1806..f07bfd7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.24' cache: true - name: Download dependencies @@ -54,7 +54,7 @@ - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.24' cache: true - name: golangci-lint diff --git a/.gitignore b/.gitignore index 62e4365..9d85bac 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ Thumbs.db # Temporary files tmp/ temp/ +docs/plans/*.md # Scripts scripts/ diff --git a/pkg/parser/php/laravel/adapter.go b/pkg/parser/php/laravel/adapter.go index 554d1f7..c1971ac 100644 --- a/pkg/parser/php/laravel/adapter.go +++ b/pkg/parser/php/laravel/adapter.go @@ -3,6 +3,7 @@ package laravel import ( "fmt" "io/fs" + "os" "path/filepath" "strings" @@ -199,3 +200,135 @@ func (a *Adapter) convertRoutesToChunks(routes []Route) []php.CodeChunk { return chunks } + +// findBladeFiles searches recursively for .blade.php files in the given paths. +// Skips vendor/, node_modules/, .git/, and hidden directories. +func (a *Adapter) findBladeFiles(paths []string) []string { + var bladeFiles []string + + for _, root := range paths { + info, err := os.Stat(root) + if err != nil { + continue + } + + // Single file check + if !info.IsDir() { + if strings.HasSuffix(root, ".blade.php") { + bladeFiles = append(bladeFiles, root) + } + continue + } + + // Walk directory + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + base := d.Name() + if base == "vendor" || base == "node_modules" || base == ".git" || + strings.HasPrefix(base, ".") { + if path != root { + return filepath.SkipDir + } + } + return nil + } + if strings.HasSuffix(d.Name(), ".blade.php") { + bladeFiles = append(bladeFiles, path) + } + return nil + }) + } + + return bladeFiles +} + +// convertBladeToChunks converts BladeTemplate structs to php.CodeChunk with +// metadata and structural relations (inheritance for @extends, dependency for @include/@component). +func (a *Adapter) convertBladeToChunks(templates []BladeTemplate) []php.CodeChunk { + var chunks []php.CodeChunk + + for _, tpl := range templates { + // Build signature + sig := tpl.Name + if tpl.Extends != "" { + sig = fmt.Sprintf("@extends('%s')", tpl.Extends) + } + + // Build docstring from directives summary + var docParts []string + if tpl.Extends != "" { + docParts = append(docParts, fmt.Sprintf("Extends: %s", tpl.Extends)) + } + if len(tpl.Sections) > 0 { + names := make([]string, len(tpl.Sections)) + for i, s := range tpl.Sections { + names[i] = s.Name + } + docParts = append(docParts, fmt.Sprintf("Sections: %s", strings.Join(names, ", "))) + } + if len(tpl.Includes) > 0 { + names := make([]string, len(tpl.Includes)) + for i, inc := range tpl.Includes { + names[i] = inc.ViewName + } + docParts = append(docParts, fmt.Sprintf("Includes: %s", strings.Join(names, ", "))) + } + if len(tpl.Props) > 0 { + docParts = append(docParts, fmt.Sprintf("Props: %s", strings.Join(tpl.Props, ", "))) + } + + docstring := strings.Join(docParts, " | ") + + // Build relations + var relations []pkgParser.Relation + if tpl.Extends != "" { + relations = append(relations, pkgParser.Relation{ + TargetName: tpl.Extends, + Type: pkgParser.RelInheritance, + }) + } + for _, inc := range tpl.Includes { + relations = append(relations, pkgParser.Relation{ + TargetName: inc.ViewName, + Type: pkgParser.RelDependency, + }) + } + + endLine := tpl.TotalLines + if endLine < 1 { + endLine = 1 + } + + chunk := php.CodeChunk{ + Name: tpl.Name, + Type: "blade_template", + Language: "php", + FilePath: tpl.FilePath, + StartLine: 1, + EndLine: endLine, + Signature: sig, + Docstring: docstring, + Metadata: map[string]any{ + "framework": "laravel", + "blade": true, + "sections_count": len(tpl.Sections), + "includes_count": len(tpl.Includes), + }, + Relations: relations, + } + + if len(tpl.Stacks) > 0 { + chunk.Metadata["stacks"] = tpl.Stacks + } + if len(tpl.Props) > 0 { + chunk.Metadata["props"] = tpl.Props + } + + chunks = append(chunks, chunk) + } + + return chunks +} diff --git a/pkg/parser/php/laravel/blade.go b/pkg/parser/php/laravel/blade.go new file mode 100644 index 0000000..9969d3c --- /dev/null +++ b/pkg/parser/php/laravel/blade.go @@ -0,0 +1,181 @@ +package laravel + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/doITmagic/rag-code-mcp/internal/logger" +) + +// Compiled regex patterns for Blade directives +var ( + reExtends = regexp.MustCompile(`@extends\(\s*['"](.+?)['"]\s*\)`) + reSection = regexp.MustCompile(`@section\(\s*['"](.+?)['"]\s*(?:,.*?)?\)`) + reYield = regexp.MustCompile(`@yield\(\s*['"](.+?)['"]\s*\)`) + reInclude = regexp.MustCompile(`@include\(\s*['"](.+?)['"]\s*\)`) + reComponent = regexp.MustCompile(`@component\(\s*['"](.+?)['"]\s*\)`) + reEach = regexp.MustCompile(`@each\(\s*['"](.+?)['"]\s*\)`) + rePushStack = regexp.MustCompile(`@(?:push|stack)\(\s*['"](.+?)['"]\s*\)`) + reProps = regexp.MustCompile(`@props\(\s*\[(.*?)\]\s*\)`) +) + +// BladeAnalyzer parses Blade template files and extracts directives. +type BladeAnalyzer struct{} + +// NewBladeAnalyzer creates a new BladeAnalyzer. +func NewBladeAnalyzer() *BladeAnalyzer { + return &BladeAnalyzer{} +} + +// Analyze parses the given Blade template files, extracting directives. +// Files that cannot be read are logged and skipped (no error returned). +func (ba *BladeAnalyzer) Analyze(filePaths []string) []BladeTemplate { + var templates []BladeTemplate + + for _, fp := range filePaths { + tpl, err := ba.analyzeFile(fp) + if err != nil { + logger.Instance.Debug("[BLADE] skip %s: %v", filepath.Base(fp), err) + continue + } + templates = append(templates, tpl) + } + + return templates +} + +// analyzeFile parses a single Blade file. +func (ba *BladeAnalyzer) analyzeFile(filePath string) (BladeTemplate, error) { + f, err := os.Open(filePath) + if err != nil { + return BladeTemplate{}, err + } + defer f.Close() + + tpl := BladeTemplate{ + Name: bladeViewName(filePath), + FilePath: filePath, + } + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) // Allow lines up to 1MB + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := scanner.Text() + + // @extends + if m := reExtends.FindStringSubmatch(line); len(m) > 1 { + tpl.Extends = m[1] + } + + // @section + if m := reSection.FindStringSubmatch(line); len(m) > 1 { + tpl.Sections = append(tpl.Sections, BladeSection{ + Name: m[1], + Type: "section", + StartLine: lineNum, + }) + } + + // @yield + if m := reYield.FindStringSubmatch(line); len(m) > 1 { + tpl.Sections = append(tpl.Sections, BladeSection{ + Name: m[1], + Type: "yield", + StartLine: lineNum, + }) + } + + // @include + if m := reInclude.FindStringSubmatch(line); len(m) > 1 { + tpl.Includes = append(tpl.Includes, BladeInclude{ + ViewName: m[1], + Type: "include", + Line: lineNum, + }) + } + + // @component + if m := reComponent.FindStringSubmatch(line); len(m) > 1 { + tpl.Includes = append(tpl.Includes, BladeInclude{ + ViewName: m[1], + Type: "component", + Line: lineNum, + }) + } + + // @each + if m := reEach.FindStringSubmatch(line); len(m) > 1 { + tpl.Includes = append(tpl.Includes, BladeInclude{ + ViewName: m[1], + Type: "each", + Line: lineNum, + }) + } + + // @push / @stack + if m := rePushStack.FindStringSubmatch(line); len(m) > 1 { + tpl.Stacks = appendUnique(tpl.Stacks, m[1]) + } + + // @props + if m := reProps.FindStringSubmatch(line); len(m) > 1 { + props := parsePropsArray(m[1]) + tpl.Props = append(tpl.Props, props...) + } + } + + tpl.TotalLines = lineNum + + return tpl, scanner.Err() +} + +// bladeViewName converts a file path to Laravel dot notation. +// Example: /project/resources/views/layouts/app.blade.php → layouts.app +func bladeViewName(filePath string) string { + // Normalize to forward slashes + fp := filepath.ToSlash(filePath) + + // Try to find resources/views/ in the path + marker := "resources/views/" + idx := strings.LastIndex(fp, marker) + if idx >= 0 { + relative := fp[idx+len(marker):] + // Remove .blade.php extension + relative = strings.TrimSuffix(relative, ".blade.php") + return strings.ReplaceAll(relative, "/", ".") + } + + // Fallback: use basename without extension + base := filepath.Base(filePath) + return strings.TrimSuffix(base, ".blade.php") +} + +// parsePropsArray extracts prop names from a @props([...]) content string. +// Input: "'title', 'color'" → Output: ["title", "color"] +func parsePropsArray(raw string) []string { + var props []string + parts := strings.Split(raw, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + p = strings.Trim(p, "'\"") + if p != "" { + props = append(props, p) + } + } + return props +} + +// appendUnique appends s to slice only if not already present. +func appendUnique(slice []string, s string) []string { + for _, existing := range slice { + if existing == s { + return slice + } + } + return append(slice, s) +} diff --git a/pkg/parser/php/laravel/blade_test.go b/pkg/parser/php/laravel/blade_test.go new file mode 100644 index 0000000..b043d73 --- /dev/null +++ b/pkg/parser/php/laravel/blade_test.go @@ -0,0 +1,245 @@ +package laravel + +import ( + "os" + "path/filepath" + "testing" +) + +// writeTempBlade creates a temp .blade.php file inside resources/views/ and returns its path. +func writeTempBlade(t *testing.T, dir, name, content string) string { + t.Helper() + viewsDir := filepath.Join(dir, "resources", "views") + fullPath := filepath.Join(viewsDir, name) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return fullPath +} + +func TestBladeAnalyzer_Extends(t *testing.T) { + dir := t.TempDir() + fp := writeTempBlade(t, dir, "pages/home.blade.php", + `@extends('layouts.app') +
Hello
`) + + ba := NewBladeAnalyzer() + templates := ba.Analyze([]string{fp}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + tpl := templates[0] + if tpl.Extends != "layouts.app" { + t.Errorf("Extends = %q, want %q", tpl.Extends, "layouts.app") + } + if tpl.Name != "pages.home" { + t.Errorf("Name = %q, want %q", tpl.Name, "pages.home") + } +} + +func TestBladeAnalyzer_Sections(t *testing.T) { + dir := t.TempDir() + fp := writeTempBlade(t, dir, "layouts/app.blade.php", + ` +@yield('title') + +@yield('content') +@section('sidebar') + default sidebar +@endsection + +`) + + ba := NewBladeAnalyzer() + templates := ba.Analyze([]string{fp}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + tpl := templates[0] + if len(tpl.Sections) != 3 { + t.Fatalf("expected 3 sections, got %d: %+v", len(tpl.Sections), tpl.Sections) + } + + yields := 0 + sections := 0 + for _, s := range tpl.Sections { + switch s.Type { + case "yield": + yields++ + case "section": + sections++ + } + } + if yields != 2 { + t.Errorf("expected 2 yields, got %d", yields) + } + if sections != 1 { + t.Errorf("expected 1 section, got %d", sections) + } +} + +func TestBladeAnalyzer_Includes(t *testing.T) { + dir := t.TempDir() + fp := writeTempBlade(t, dir, "pages/show.blade.php", + `@extends('layouts.app') +@include('partials.header') +@component('components.alert') + Alert content +@endcomponent +@each('partials.item', $items, 'item')`) + + ba := NewBladeAnalyzer() + templates := ba.Analyze([]string{fp}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + tpl := templates[0] + if len(tpl.Includes) != 3 { + t.Fatalf("expected 3 includes, got %d: %+v", len(tpl.Includes), tpl.Includes) + } + + types := map[string]int{} + for _, inc := range tpl.Includes { + types[inc.Type]++ + } + if types["include"] != 1 || types["component"] != 1 || types["each"] != 1 { + t.Errorf("unexpected include types: %v", types) + } +} + +func TestBladeAnalyzer_PushStack(t *testing.T) { + dir := t.TempDir() + fp := writeTempBlade(t, dir, "layouts/app.blade.php", + ` +@stack('styles') + + +@stack('scripts') +`) + + ba := NewBladeAnalyzer() + templates := ba.Analyze([]string{fp}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + if len(templates[0].Stacks) != 2 { + t.Errorf("expected 2 stacks, got %d: %v", len(templates[0].Stacks), templates[0].Stacks) + } +} + +func TestBladeAnalyzer_Props(t *testing.T) { + dir := t.TempDir() + fp := writeTempBlade(t, dir, "components/alert.blade.php", + `@props(['title', 'color', 'dismissible']) +
+

{{ $title }}

+ {{ $slot }} +
`) + + ba := NewBladeAnalyzer() + templates := ba.Analyze([]string{fp}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + if len(templates[0].Props) != 3 { + t.Errorf("expected 3 props, got %d: %v", len(templates[0].Props), templates[0].Props) + } +} + +func TestBladeAnalyzer_EmptyFile(t *testing.T) { + dir := t.TempDir() + fp := writeTempBlade(t, dir, "empty.blade.php", "") + + ba := NewBladeAnalyzer() + templates := ba.Analyze([]string{fp}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template (even if empty), got %d", len(templates)) + } + tpl := templates[0] + if tpl.Extends != "" || len(tpl.Sections) != 0 || len(tpl.Includes) != 0 { + t.Errorf("empty template should have no directives, got %+v", tpl) + } +} + +func TestBladeAnalyzer_NonexistentFile(t *testing.T) { + ba := NewBladeAnalyzer() + templates := ba.Analyze([]string{"/nonexistent/file.blade.php"}) + + if len(templates) != 0 { + t.Errorf("expected 0 templates for nonexistent file, got %d", len(templates)) + } +} + +func TestBladeAnalyzer_ComplexTemplate(t *testing.T) { + dir := t.TempDir() + fp := writeTempBlade(t, dir, "pages/dashboard.blade.php", + `@extends('layouts.admin') + +@section('title', 'Dashboard') + +@section('content') +
+ @include('partials.stats') + @include('partials.charts') + @component('components.card') +

Welcome

+ @endcomponent +
+@endsection + +@push('scripts') + +@endpush + +@push('styles') + +@endpush`) + + ba := NewBladeAnalyzer() + templates := ba.Analyze([]string{fp}) + + if len(templates) != 1 { + t.Fatalf("expected 1 template, got %d", len(templates)) + } + tpl := templates[0] + + if tpl.Extends != "layouts.admin" { + t.Errorf("Extends = %q, want %q", tpl.Extends, "layouts.admin") + } + if len(tpl.Sections) != 2 { + t.Errorf("expected 2 sections, got %d", len(tpl.Sections)) + } + if len(tpl.Includes) != 3 { + t.Errorf("expected 3 includes (2 include + 1 component), got %d", len(tpl.Includes)) + } + if len(tpl.Stacks) != 2 { + t.Errorf("expected 2 stacks (scripts, styles), got %d", len(tpl.Stacks)) + } +} + +func TestBladeViewName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"/project/resources/views/layouts/app.blade.php", "layouts.app"}, + {"/project/resources/views/pages/home.blade.php", "pages.home"}, + {"/project/resources/views/welcome.blade.php", "welcome"}, + {"/random/path/file.blade.php", "file"}, + } + for _, tt := range tests { + got := bladeViewName(tt.input) + if got != tt.want { + t.Errorf("bladeViewName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/pkg/parser/php/laravel/enricher.go b/pkg/parser/php/laravel/enricher.go index 10e1c4f..bad867d 100644 --- a/pkg/parser/php/laravel/enricher.go +++ b/pkg/parser/php/laravel/enricher.go @@ -123,6 +123,21 @@ func (e *Enricher) Enrich(ca *php.CodeAnalyzer, packages []*php.PackageInfo, pat } } + logger.Instance.Debug("[LARAVEL] Enrich: %d chunks after routes, before blade analysis", len(chunks)) + + // Analyze Blade Templates + bladeFiles := e.adapter.findBladeFiles(paths) + logger.Instance.Debug("[LARAVEL] Enrich: found %d blade files from paths=%v", len(bladeFiles), paths) + if len(bladeFiles) > 0 { + bladeAnalyzer := NewBladeAnalyzer() + bladeTemplates := bladeAnalyzer.Analyze(bladeFiles) + if len(bladeTemplates) > 0 { + bladeChunks := e.adapter.convertBladeToChunks(bladeTemplates) + logger.Instance.Debug("[LARAVEL] Enrich: %d blade templates → %d chunks", len(bladeTemplates), len(bladeChunks)) + chunks = append(chunks, bladeChunks...) + } + } + logger.Instance.Debug("[LARAVEL] Enrich DONE: returning %d total chunks", len(chunks)) return chunks } diff --git a/pkg/parser/php/laravel/types.go b/pkg/parser/php/laravel/types.go index cf17c1e..83e2602 100644 --- a/pkg/parser/php/laravel/types.go +++ b/pkg/parser/php/laravel/types.go @@ -2,11 +2,12 @@ package laravel // LaravelInfo contains Laravel-specific framework information extracted from a project type LaravelInfo struct { - Models []EloquentModel `json:"models"` - Controllers []Controller `json:"controllers"` - Routes []Route `json:"routes"` - Migrations []Migration `json:"migrations,omitempty"` - Middleware []Middleware `json:"middleware,omitempty"` + Models []EloquentModel `json:"models"` + Controllers []Controller `json:"controllers"` + Routes []Route `json:"routes"` + Migrations []Migration `json:"migrations,omitempty"` + Middleware []Middleware `json:"middleware,omitempty"` + BladeTemplates []BladeTemplate `json:"blade_templates,omitempty"` } // EloquentModel represents a Laravel Eloquent model with ORM features @@ -128,3 +129,29 @@ type Middleware struct { StartLine int `json:"start_line"` EndLine int `json:"end_line"` } + +// BladeTemplate represents a parsed Blade template with its directives +type BladeTemplate struct { + Name string `json:"name"` // dot notation: layouts.app + FilePath string `json:"file_path"` + TotalLines int `json:"total_lines"` // total line count of the file + Extends string `json:"extends,omitempty"` // @extends('...') + Sections []BladeSection `json:"sections,omitempty"` + Includes []BladeInclude `json:"includes,omitempty"` + Stacks []string `json:"stacks,omitempty"` // @push/@stack names + Props []string `json:"props,omitempty"` // @props([...]) +} + +// BladeSection represents a @section or @yield directive +type BladeSection struct { + Name string `json:"name"` // section name + Type string `json:"type"` // "section" or "yield" + StartLine int `json:"start_line"` +} + +// BladeInclude represents an @include, @component, or @each directive +type BladeInclude struct { + ViewName string `json:"view_name"` // dot notation + Type string `json:"type"` // "include", "component", or "each" + Line int `json:"line"` +} diff --git a/pkg/parser/php/wordpress/analyzer.go b/pkg/parser/php/wordpress/analyzer.go index f22af76..917ab15 100644 --- a/pkg/parser/php/wordpress/analyzer.go +++ b/pkg/parser/php/wordpress/analyzer.go @@ -15,6 +15,8 @@ import ( pkgParser "github.com/doITmagic/rag-code-mcp/pkg/parser" "github.com/doITmagic/rag-code-mcp/pkg/parser/php" + "github.com/doITmagic/rag-code-mcp/pkg/parser/php/wordpress/oxygen" + "github.com/doITmagic/rag-code-mcp/pkg/parser/php/wordpress/woocommerce" ) // Analyzer is the main WordPress framework analyzer that coordinates all WordPress-specific analyzers @@ -26,6 +28,8 @@ type Analyzer struct { widgetAnalyzer *WidgetAnalyzer adminAnalyzer *AdminAnalyzer pluginHeaderAnalyzer *PluginHeaderAnalyzer + oxygenAnalyzer *oxygen.Analyzer + woocommerceAnalyzer *woocommerce.Analyzer phpAnalyzer *php.CodeAnalyzer } @@ -39,6 +43,8 @@ func NewAnalyzer() *Analyzer { widgetAnalyzer: NewWidgetAnalyzer(), adminAnalyzer: NewAdminAnalyzer(), pluginHeaderAnalyzer: NewPluginHeaderAnalyzer(), + oxygenAnalyzer: oxygen.NewAnalyzer(), + woocommerceAnalyzer: woocommerce.NewAnalyzer(), phpAnalyzer: php.NewCodeAnalyzer(), } } @@ -74,6 +80,7 @@ func (a *Analyzer) AnalyzePaths(paths []string) ([]php.CodeChunk, error) { // analyzeWordPress performs complete WordPress analysis using both package info and AST func (a *Analyzer) analyzeWordPress(packages []*php.PackageInfo, paths []string) *WordPressInfo { info := &WordPressInfo{} + var wcAPICalls []woocommerce.WCAPICall // Analyze from parsed package info (method calls) info.Hooks = a.hookAnalyzer.AnalyzeHooks(packages) @@ -143,10 +150,44 @@ func (a *Analyzer) analyzeWordPress(packages []*php.PackageInfo, paths []string) } } + // WooCommerce API calls from AST + wcInfo := a.woocommerceAnalyzer.Analyze(rootNode, path) + if wcInfo != nil && len(wcInfo.APICalls) > 0 { + wcAPICalls = append(wcAPICalls, wcInfo.APICalls...) + } + return nil }) } + // Oxygen Builder analysis (reuses already-parsed packages) + oxyInfo := a.oxygenAnalyzer.AnalyzeFromPackages(packages) + if oxyInfo != nil && (len(oxyInfo.Elements) > 0 || len(oxyInfo.Templates) > 0) { + info.OxygenInfo = oxyInfo + } + + // WooCommerce analysis: classify WP hooks that have woocommerce_ prefix + var wcInputHooks []woocommerce.WPHookInput + for _, h := range info.Hooks { + wcInputHooks = append(wcInputHooks, woocommerce.WPHookInput{ + Type: string(h.Type), + Name: h.Name, + Callback: h.Callback, + Priority: h.Priority, + FilePath: h.FilePath, + StartLine: h.StartLine, + EndLine: h.EndLine, + }) + } + wcHooks := a.woocommerceAnalyzer.AnalyzeHooksFromWP(wcInputHooks) + if len(wcHooks) > 0 || len(wcAPICalls) > 0 { + wcInfo := &woocommerce.WooCommerceInfo{ + Hooks: wcHooks, + APICalls: wcAPICalls, + } + info.WooCommerceInfo = wcInfo + } + return info } @@ -344,6 +385,96 @@ func (a *Analyzer) convertToChunks(info *WordPressInfo) []php.CodeChunk { }) } + // Convert Oxygen elements + if info.OxygenInfo != nil { + if oxyInfo, ok := info.OxygenInfo.(*oxygen.OxygenInfo); ok { + for _, elem := range oxyInfo.Elements { + baseClass := elem.BaseClass + if baseClass == "" { + baseClass = "OxyEl" + } + chunks = append(chunks, php.CodeChunk{ + Name: elem.ClassName, + Type: "oxy_element", + Language: "php", + FilePath: elem.FilePath, + StartLine: elem.StartLine, + EndLine: elem.EndLine, + Signature: fmt.Sprintf("class %s extends %s", elem.ClassName, baseClass), + Docstring: fmt.Sprintf("Oxygen Builder Element: %s (methods: %s)", elem.ClassName, strings.Join(elem.Methods, ", ")), + Metadata: map[string]any{ + "framework": "wordpress", + "wp_type": "oxygen_element", + "namespace": elem.Namespace, + "has_slug": elem.SlugMethod, + "methods": elem.Methods, + }, + }) + } + + for _, tmpl := range oxyInfo.Templates { + chunks = append(chunks, php.CodeChunk{ + Name: tmpl.PostType, + Type: "oxy_template", + Language: "php", + FilePath: tmpl.FilePath, + StartLine: tmpl.Line, + EndLine: tmpl.Line, + Signature: fmt.Sprintf("register_post_type('%s', ...)", tmpl.PostType), + Docstring: fmt.Sprintf("Oxygen Template: %s", tmpl.PostType), + Metadata: map[string]any{ + "framework": "wordpress", + "wp_type": "oxygen_template", + }, + }) + } + } + } + + // Convert WooCommerce hooks + if info.WooCommerceInfo != nil { + if wcInfo, ok := info.WooCommerceInfo.(*woocommerce.WooCommerceInfo); ok { + for _, wcHook := range wcInfo.Hooks { + chunks = append(chunks, php.CodeChunk{ + Name: wcHook.HookName, + Type: "wc_hook", + Language: "php", + FilePath: wcHook.FilePath, + StartLine: wcHook.StartLine, + EndLine: wcHook.EndLine, + Signature: buildWCHookSignature(wcHook), + Docstring: fmt.Sprintf("WooCommerce %s hook (%s area): %s", wcHook.HookType, wcHook.Area, wcHook.HookName), + Metadata: map[string]any{ + "framework": "wordpress", + "wp_type": "wc_hook", + "wc_area": string(wcHook.Area), + "hook_type": wcHook.HookType, + "callback": wcHook.Callback, + "priority": wcHook.Priority, + }, + }) + } + + for _, apiCall := range wcInfo.APICalls { + chunks = append(chunks, php.CodeChunk{ + Name: apiCall.Function, + Type: "wc_api_call", + Language: "php", + FilePath: apiCall.FilePath, + StartLine: apiCall.StartLine, + EndLine: apiCall.EndLine, + Signature: fmt.Sprintf("%s(...)", apiCall.Function), + Docstring: fmt.Sprintf("WooCommerce API call: %s (category: %s)", apiCall.Function, apiCall.Category), + Metadata: map[string]any{ + "framework": "wordpress", + "wp_type": "wc_api_call", + "wc_category": apiCall.Category, + }, + }) + } + } + } + return chunks } @@ -387,6 +518,37 @@ func buildHookSignature(hook WPHook) string { } } +// buildWCHookSignature creates a readable signature for a WooCommerce hook +// using the real WordPress function names instead of raw hook type values +func buildWCHookSignature(wcHook woocommerce.WCHook) string { + switch wcHook.HookType { + case "action": + if wcHook.Priority > 0 { + return fmt.Sprintf("add_action('%s', '%s', %d)", wcHook.HookName, wcHook.Callback, wcHook.Priority) + } + return fmt.Sprintf("add_action('%s', '%s')", wcHook.HookName, wcHook.Callback) + case "filter": + if wcHook.Priority > 0 { + return fmt.Sprintf("add_filter('%s', '%s', %d)", wcHook.HookName, wcHook.Callback, wcHook.Priority) + } + return fmt.Sprintf("add_filter('%s', '%s')", wcHook.HookName, wcHook.Callback) + case "action_trigger": + return fmt.Sprintf("do_action('%s')", wcHook.HookName) + case "filter_trigger": + return fmt.Sprintf("apply_filters('%s')", wcHook.HookName) + case "action_removal": + return fmt.Sprintf("remove_action('%s', '%s')", wcHook.HookName, wcHook.Callback) + case "filter_removal": + return fmt.Sprintf("remove_filter('%s', '%s')", wcHook.HookName, wcHook.Callback) + case "action_check": + return fmt.Sprintf("has_action('%s')", wcHook.HookName) + case "filter_check": + return fmt.Sprintf("has_filter('%s')", wcHook.HookName) + default: + return fmt.Sprintf("%s('%s', '%s')", wcHook.HookType, wcHook.HookName, wcHook.Callback) + } +} + // IsWordPressProject detects if the given paths contain a WordPress project. // It first checks the paths directly (directory-level indicators, plugin headers), // then walks UP parent directories to find WordPress root indicators. diff --git a/pkg/parser/php/wordpress/analyzer_test.go b/pkg/parser/php/wordpress/analyzer_test.go index 8bb7d87..82226b7 100644 --- a/pkg/parser/php/wordpress/analyzer_test.go +++ b/pkg/parser/php/wordpress/analyzer_test.go @@ -385,3 +385,138 @@ func TestMergePostTypes_Deduplication(t *testing.T) { t.Errorf("expected 2 post types after merge, got %d", len(result)) } } + +func TestAnalyzer_OxygenElementDetection(t *testing.T) { + tmpDir := t.TempDir() + + // Plugin header to detect as WordPress + err := os.WriteFile(filepath.Join(tmpDir, "plugin.php"), []byte(`Custom'; + } +} +`), 0644) + if err != nil { + t.Fatal(err) + } + + analyzer := NewAnalyzer() + chunks, err := analyzer.AnalyzePaths([]string{tmpDir}) + if err != nil { + t.Fatalf("AnalyzePaths failed: %v", err) + } + + // Find oxy_element chunks + var oxyChunks []string + for _, c := range chunks { + if c.Type == "oxy_element" { + oxyChunks = append(oxyChunks, c.Name) + if c.Metadata["framework"] != "wordpress" { + t.Errorf("Oxygen chunk %s: expected framework=wordpress", c.Name) + } + if c.Metadata["wp_type"] != "oxygen_element" { + t.Errorf("Oxygen chunk %s: expected wp_type=oxygen_element, got %v", c.Name, c.Metadata["wp_type"]) + } + } + } + + if len(oxyChunks) == 0 { + t.Error("expected at least 1 oxy_element chunk, got 0") + } else { + t.Logf("Found Oxygen elements: %v", oxyChunks) + } +} + +func TestAnalyzer_WooCommerceHookClassification(t *testing.T) { + tmpDir := t.TempDir() + + // Plugin header + err := os.WriteFile(filepath.Join(tmpDir, "plugin.php"), []byte(`= 0 { + return extends[idx+1:] + } + return extends +} + // extractFuncName extracts function name from AST node func extractFuncName(node ast.Vertex) string { if node == nil { diff --git a/pkg/parser/php/wordpress/oxygen/types.go b/pkg/parser/php/wordpress/oxygen/types.go index dfe6c1e..8366e1a 100644 --- a/pkg/parser/php/wordpress/oxygen/types.go +++ b/pkg/parser/php/wordpress/oxygen/types.go @@ -12,8 +12,9 @@ type OxygenElement struct { ClassName string `json:"class_name"` Namespace string `json:"namespace,omitempty"` FullName string `json:"full_name"` - SlugMethod bool `json:"has_slug"` // Has slug() method - Methods []string `json:"methods,omitempty"` // Detected methods (init, name, slug, icon, controls, render) + BaseClass string `json:"base_class,omitempty"` // OxyEl, OxyElShadow, OxygenElement, etc. + SlugMethod bool `json:"has_slug"` // Has slug() method + Methods []string `json:"methods,omitempty"` // Detected methods (init, name, slug, icon, controls, render) FilePath string `json:"file_path"` StartLine int `json:"start_line"` EndLine int `json:"end_line"` diff --git a/pkg/parser/php/wordpress/types.go b/pkg/parser/php/wordpress/types.go index ace002a..397c1de 100644 --- a/pkg/parser/php/wordpress/types.go +++ b/pkg/parser/php/wordpress/types.go @@ -2,16 +2,18 @@ package wordpress // WordPressInfo contains WordPress-specific framework information extracted from a project type WordPressInfo struct { - Hooks []WPHook `json:"hooks,omitempty"` - PostTypes []PostType `json:"post_types,omitempty"` - Taxonomies []Taxonomy `json:"taxonomies,omitempty"` - Shortcodes []Shortcode `json:"shortcodes,omitempty"` - Blocks []Block `json:"blocks,omitempty"` - BlockPatterns []BlockPattern `json:"block_patterns,omitempty"` - Widgets []Widget `json:"widgets,omitempty"` - AdminPages []AdminPage `json:"admin_pages,omitempty"` - Settings []Setting `json:"settings,omitempty"` - PluginHeader *PluginHeader `json:"plugin_header,omitempty"` + Hooks []WPHook `json:"hooks,omitempty"` + PostTypes []PostType `json:"post_types,omitempty"` + Taxonomies []Taxonomy `json:"taxonomies,omitempty"` + Shortcodes []Shortcode `json:"shortcodes,omitempty"` + Blocks []Block `json:"blocks,omitempty"` + BlockPatterns []BlockPattern `json:"block_patterns,omitempty"` + Widgets []Widget `json:"widgets,omitempty"` + AdminPages []AdminPage `json:"admin_pages,omitempty"` + Settings []Setting `json:"settings,omitempty"` + PluginHeader *PluginHeader `json:"plugin_header,omitempty"` + OxygenInfo any `json:"oxygen,omitempty"` // *oxygen.OxygenInfo (avoid import cycle) + WooCommerceInfo any `json:"woocommerce,omitempty"` // *woocommerce.WooCommerceInfo (avoid import cycle) } // HookType represents the type of a WordPress hook diff --git a/pkg/parser/php/wordpress/woocommerce/analyzer.go b/pkg/parser/php/wordpress/woocommerce/analyzer.go index bc1273a..85056a3 100644 --- a/pkg/parser/php/wordpress/woocommerce/analyzer.go +++ b/pkg/parser/php/wordpress/woocommerce/analyzer.go @@ -1,12 +1,24 @@ package woocommerce import ( + "strconv" "strings" "github.com/VKCOM/php-parser/pkg/ast" - "github.com/doITmagic/rag-code-mcp/pkg/parser/php/wordpress" ) +// WPHookInput is a local mirror of wordpress.WPHook to avoid import cycles. +// The wordpress/analyzer.go converts WPHook to WPHookInput before calling AnalyzeHooksFromWP. +type WPHookInput struct { + Type string + Name string + Callback string + Priority int + FilePath string + StartLine int + EndLine int +} + // areaRule maps a keyword to a WC functional area type areaRule struct { keyword string @@ -55,15 +67,11 @@ var wcApiFunctions = map[string]string{ } // Analyzer detects WooCommerce-specific patterns in WordPress code -type Analyzer struct { - astHelper *wordpress.ASTHelper -} +type Analyzer struct{} // NewAnalyzer creates a new WooCommerce analyzer func NewAnalyzer() *Analyzer { - return &Analyzer{ - astHelper: wordpress.NewASTHelper(), - } + return &Analyzer{} } // Analyze performs WooCommerce-specific analysis on AST @@ -73,9 +81,9 @@ func (a *Analyzer) Analyze(root ast.Vertex, filePath string) *WooCommerceInfo { return info } -// AnalyzeHooksFromWP classifies existing WordPress hooks as WooCommerce hooks -// This takes already-detected WP hooks and enriches them with WC area info -func (a *Analyzer) AnalyzeHooksFromWP(wpHooks []wordpress.WPHook) []WCHook { +// AnalyzeHooksFromWP classifies existing WordPress hooks as WooCommerce hooks. +// Uses WPHookInput to avoid import cycle with the wordpress parent package. +func (a *Analyzer) AnalyzeHooksFromWP(wpHooks []WPHookInput) []WCHook { var wcHooks []WCHook for _, h := range wpHooks { @@ -86,7 +94,7 @@ func (a *Analyzer) AnalyzeHooksFromWP(wpHooks []wordpress.WPHook) []WCHook { wcHook := WCHook{ HookName: h.Name, Area: classifyHookArea(h.Name), - HookType: string(h.Type), + HookType: h.Type, Callback: h.Callback, Priority: h.Priority, FilePath: h.FilePath, @@ -160,23 +168,13 @@ func (a *Analyzer) walk(node ast.Vertex, filePath string, info *WooCommerceInfo) a.walk(n.Expr, filePath, info) case *ast.ExprFunctionCall: // Check for WC hooks - hook := a.astHelper.ExtractHookFromFunctionCall(n, filePath) - if hook != nil && strings.HasPrefix(hook.Name, "woocommerce_") { - wcHook := WCHook{ - HookName: hook.Name, - Area: classifyHookArea(hook.Name), - HookType: string(hook.Type), - Callback: hook.Callback, - Priority: hook.Priority, - FilePath: hook.FilePath, - StartLine: hook.StartLine, - EndLine: hook.EndLine, - } - info.Hooks = append(info.Hooks, wcHook) + hook := extractHookFromFunctionCall(n, filePath) + if hook != nil && strings.HasPrefix(hook.HookName, "woocommerce_") { + info.Hooks = append(info.Hooks, *hook) } // Check for WC API calls - funcName := a.extractFuncName(n.Function) + funcName := extractFuncName(n.Function) if category, ok := wcApiFunctions[funcName]; ok { info.APICalls = append(info.APICalls, WCAPICall{ Function: funcName, @@ -189,10 +187,10 @@ func (a *Analyzer) walk(node ast.Vertex, filePath string, info *WooCommerceInfo) case *ast.ExprStaticCall: // Check for WC()->... static calls - className := a.extractClassName(n.Class) + className := extractClassName(n.Class) if className == "WC" { info.APICalls = append(info.APICalls, WCAPICall{ - Function: "WC::" + a.extractMethodName(n.Call), + Function: "WC::" + extractMethodName(n.Call), Category: "core", FilePath: filePath, StartLine: n.Position.StartLine, @@ -216,8 +214,67 @@ func classifyHookArea(hookName string) WCHookArea { return WCAreaGeneral } +// --- Local AST helpers (avoid importing wordpress parent package) --- + +// hookFuncMap maps WordPress hook function names to their HookType string +var hookFuncMap = map[string]string{ + "add_action": "action", + "add_filter": "filter", + "do_action": "action_trigger", + "apply_filters": "filter_trigger", + "remove_action": "action_removal", + "remove_filter": "filter_removal", + "has_filter": "filter_check", + "has_action": "action_check", +} + +// extractHookFromFunctionCall checks if a function call is a WordPress hook and returns a WCHook if it's WC-related +func extractHookFromFunctionCall(call *ast.ExprFunctionCall, filePath string) *WCHook { + funcName := extractFuncName(call.Function) + if funcName == "" { + return nil + } + + hookType, ok := hookFuncMap[funcName] + if !ok { + return nil + } + + // Extract arguments + args := extractCallArgs(call.Args) + if len(args) == 0 { + return nil + } + + hookName := args[0] + + hook := &WCHook{ + HookName: hookName, + Area: classifyHookArea(hookName), + HookType: hookType, + FilePath: filePath, + StartLine: call.Position.StartLine, + EndLine: call.Position.EndLine, + } + + // For add/remove hooks: callback, priority + if hookType == "action" || hookType == "filter" || + hookType == "action_removal" || hookType == "filter_removal" { + if len(args) > 1 { + hook.Callback = args[1] + } + if len(args) > 2 { + if p, err := strconv.Atoi(args[2]); err == nil { + hook.Priority = p + } + } + } + + return hook +} + // extractFuncName extracts function name from AST node -func (a *Analyzer) extractFuncName(node ast.Vertex) string { +func extractFuncName(node ast.Vertex) string { if node == nil { return "" } @@ -230,17 +287,25 @@ func (a *Analyzer) extractFuncName(node ast.Vertex) string { } } return strings.Join(parts, "\\") + case *ast.NameFullyQualified: + var parts []string + for _, part := range n.Parts { + if namePart, ok := part.(*ast.NamePart); ok { + parts = append(parts, string(namePart.Value)) + } + } + return strings.Join(parts, "\\") } return "" } // extractClassName extracts class name from AST node -func (a *Analyzer) extractClassName(node ast.Vertex) string { - return a.extractFuncName(node) +func extractClassName(node ast.Vertex) string { + return extractFuncName(node) } // extractMethodName extracts method name from AST node -func (a *Analyzer) extractMethodName(node ast.Vertex) string { +func extractMethodName(node ast.Vertex) string { if node == nil { return "" } @@ -249,3 +314,78 @@ func (a *Analyzer) extractMethodName(node ast.Vertex) string { } return "" } + +// extractCallArgs extracts string arguments from a function call +func extractCallArgs(args []ast.Vertex) []string { + var result []string + for _, arg := range args { + if argNode, ok := arg.(*ast.Argument); ok { + val := extractExprValue(argNode.Expr) + result = append(result, val) + } + } + return result +} + +// extractExprValue extracts a string representation from an expression +func extractExprValue(expr ast.Vertex) string { + if expr == nil { + return "" + } + switch n := expr.(type) { + case *ast.ScalarString: + val := string(n.Value) + if len(val) >= 2 { + val = val[1 : len(val)-1] // Remove quotes + } + return val + case *ast.ScalarLnumber: + return string(n.Value) + case *ast.ScalarDnumber: + return string(n.Value) + case *ast.Name: + var parts []string + for _, part := range n.Parts { + if namePart, ok := part.(*ast.NamePart); ok { + parts = append(parts, string(namePart.Value)) + } + } + return strings.Join(parts, "\\") + case *ast.ExprConstFetch: + return extractExprValue(n.Const) + case *ast.ExprClassConstFetch: + if constName, ok := n.Const.(*ast.Identifier); ok { + if string(constName.Value) == "class" { + return extractExprValue(n.Class) + "::class" + } + } + case *ast.ExprVariable: + if nameNode, ok := n.Name.(*ast.Identifier); ok { + name := string(nameNode.Value) + if strings.HasPrefix(name, "$") { + return name + } + return "$" + name + } + case *ast.ExprArray: + if len(n.Items) == 2 { + items := make([]string, 0, 2) + for _, item := range n.Items { + if arrayItem, ok := item.(*ast.ExprArrayItem); ok { + items = append(items, extractExprValue(arrayItem.Val)) + } + } + if len(items) == 2 { + return items[0] + "::" + items[1] + } + } + return "[array]" + case *ast.ExprClosure: + return "[closure]" + case *ast.ExprArrowFunction: + return "[arrow_fn]" + case *ast.Identifier: + return string(n.Value) + } + return "" +} diff --git a/pkg/parser/php/wordpress/woocommerce/analyzer_test.go b/pkg/parser/php/wordpress/woocommerce/analyzer_test.go index ee0f668..e16b388 100644 --- a/pkg/parser/php/wordpress/woocommerce/analyzer_test.go +++ b/pkg/parser/php/wordpress/woocommerce/analyzer_test.go @@ -8,8 +8,6 @@ import ( "github.com/VKCOM/php-parser/pkg/errors" "github.com/VKCOM/php-parser/pkg/parser" "github.com/VKCOM/php-parser/pkg/version" - - "github.com/doITmagic/rag-code-mcp/pkg/parser/php/wordpress" ) func parsePHP(t *testing.T, code string) ast.Vertex { @@ -167,12 +165,12 @@ class MyWCPlugin { } func TestAnalyzeHooksFromWP(t *testing.T) { - wpHooks := []wordpress.WPHook{ - {Type: wordpress.HookAction, Name: "init", Callback: "my_init", FilePath: "test.php", StartLine: 1}, - {Type: wordpress.HookAction, Name: "woocommerce_before_cart", Callback: "cart_fn", FilePath: "test.php", StartLine: 2, Priority: 20}, - {Type: wordpress.HookFilter, Name: "woocommerce_product_get_price", Callback: "price_fn", FilePath: "test.php", StartLine: 3}, - {Type: wordpress.HookAction, Name: "wp_enqueue_scripts", Callback: "enqueue", FilePath: "test.php", StartLine: 4}, - {Type: wordpress.HookActionTrigger, Name: "woocommerce_checkout_process", FilePath: "test.php", StartLine: 5}, + wpHooks := []WPHookInput{ + {Type: "action", Name: "init", Callback: "my_init", FilePath: "test.php", StartLine: 1}, + {Type: "action", Name: "woocommerce_before_cart", Callback: "cart_fn", FilePath: "test.php", StartLine: 2, Priority: 20}, + {Type: "filter", Name: "woocommerce_product_get_price", Callback: "price_fn", FilePath: "test.php", StartLine: 3}, + {Type: "action", Name: "wp_enqueue_scripts", Callback: "enqueue", FilePath: "test.php", StartLine: 4}, + {Type: "action_trigger", Name: "woocommerce_checkout_process", FilePath: "test.php", StartLine: 5}, } analyzer := NewAnalyzer()