From 1cf637b3878d1995c2751d4f216187ab81101460 Mon Sep 17 00:00:00 2001 From: razvan Date: Mon, 16 Mar 2026 21:23:45 +0200 Subject: [PATCH 1/5] feat(laravel): add Blade Template (.blade.php) semantic indexing- Add BladeTemplate, BladeSection, BladeInclude types in types.go- Implement BladeAnalyzer with 8 regex extractors for Blade directives (@extends, @section, @yield, @include, @component, @each, @push/@stack, @props)- Add findBladeFiles() for recursive .blade.php discovery- Add convertBladeToChunks() with inheritance/dependency relations- Integrate BladeAnalyzer into Laravel Enrich() pipeline- Add 9 comprehensive unit tests (all passing)Closes: Trello cards #104-#111 --- pkg/parser/php/laravel/adapter.go | 127 ++++++++++++++ pkg/parser/php/laravel/blade.go | 178 +++++++++++++++++++ pkg/parser/php/laravel/blade_test.go | 245 +++++++++++++++++++++++++++ pkg/parser/php/laravel/enricher.go | 15 ++ pkg/parser/php/laravel/types.go | 36 +++- 5 files changed, 596 insertions(+), 5 deletions(-) create mode 100644 pkg/parser/php/laravel/blade.go create mode 100644 pkg/parser/php/laravel/blade_test.go diff --git a/pkg/parser/php/laravel/adapter.go b/pkg/parser/php/laravel/adapter.go index 554d1f7..9fb6a90 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,129 @@ 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, + }) + } + + chunk := php.CodeChunk{ + Name: tpl.Name, + Type: "blade_template", + Language: "php", + FilePath: tpl.FilePath, + StartLine: 1, + 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..9e2ba07 --- /dev/null +++ b/pkg/parser/php/laravel/blade.go @@ -0,0 +1,178 @@ +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) + 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...) + } + } + + 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..7f48a09 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 DONE: returning %d total chunks (before blade)", 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..2b44429 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,28 @@ 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"` + 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"` +} From 059ffee392378dcf3bf3ccf0c8424b782c37f0de Mon Sep 17 00:00:00 2001 From: razvan Date: Mon, 16 Mar 2026 22:08:54 +0200 Subject: [PATCH 2/5] feat: integrate Oxygen and WooCommerce analyzers into WordPress pipeline - Add OxygenInfo and WooCommerceInfo fields to WordPressInfo struct - Add oxygen.Analyzer and woocommerce.Analyzer to WordPress Analyzer - Call Oxygen analyzer in analyzeWordPress() for OxyEl element detection - Call WooCommerce analyzer for hook classification by area (cart, checkout, product, etc.) - Convert Oxygen elements/templates and WC hooks/API calls to CodeChunks - Break import cycle: woocommerce package no longer imports wordpress - Define WPHookInput in woocommerce as local mirror of WPHook - Reimplement AST helpers locally in woocommerce package - Add integration tests: - TestAnalyzer_OxygenElementDetection (end-to-end OxyEl detection) - TestAnalyzer_WooCommerceHookClassification (end-to-end WC area classification) - TestConvertToChunks_OxygenAndWooCommerce (nil safety) --- .gitignore | 1 + pkg/parser/php/wordpress/analyzer.go | 117 +++++++++++ pkg/parser/php/wordpress/analyzer_test.go | 135 +++++++++++++ pkg/parser/php/wordpress/types.go | 22 ++- .../php/wordpress/woocommerce/analyzer.go | 184 +++++++++++++++--- .../wordpress/woocommerce/analyzer_test.go | 14 +- 6 files changed, 424 insertions(+), 49 deletions(-) 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/wordpress/analyzer.go b/pkg/parser/php/wordpress/analyzer.go index f22af76..07d9602 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(), } } @@ -147,6 +153,31 @@ func (a *Analyzer) analyzeWordPress(packages []*php.PackageInfo, paths []string) }) } + // 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 { + wcInfo := &woocommerce.WooCommerceInfo{Hooks: wcHooks} + info.WooCommerceInfo = wcInfo + } + return info } @@ -344,6 +375,92 @@ 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 { + 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 OxyEl", elem.ClassName), + 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: fmt.Sprintf("%s('%s', '%s')", wcHook.HookType, wcHook.HookName, wcHook.Callback), + 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 } 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(`... 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 "" } @@ -235,12 +292,12 @@ func (a *Analyzer) extractFuncName(node ast.Vertex) string { } // 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 +306,68 @@ 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.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.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() From 4cbac50eb7d168d7997b0c4ffb2b31a762555e21 Mon Sep 17 00:00:00 2001 From: razvan Date: Tue, 17 Mar 2026 23:35:27 +0200 Subject: [PATCH 3/5] fix: address PR #46 review comments (6 fixes) - Fix misleading 'Enrich DONE' log emitted before Blade analysis (enricher.go) - Fix wc_hook signature to use real WP functions (add_action/add_filter/etc.) - Populate WooCommerce APICalls via woocommerceAnalyzer.Analyze() in AST walk - Add missing AST cases to extractExprValue (ScalarDnumber, ExprConstFetch, ExprClassConstFetch) - Fix Blade reSection regex to capture inline sections like @section('title', 'Dashboard') - Set EndLine on blade_template chunks using TotalLines from BladeTemplate struct Signed-off-by: doITmagic --- pkg/parser/php/laravel/adapter.go | 6 +++ pkg/parser/php/laravel/blade.go | 4 +- pkg/parser/php/laravel/enricher.go | 2 +- pkg/parser/php/laravel/types.go | 15 ++++--- pkg/parser/php/wordpress/analyzer.go | 43 +++++++++++++++++-- .../php/wordpress/woocommerce/analyzer.go | 10 +++++ 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/pkg/parser/php/laravel/adapter.go b/pkg/parser/php/laravel/adapter.go index 9fb6a90..c1971ac 100644 --- a/pkg/parser/php/laravel/adapter.go +++ b/pkg/parser/php/laravel/adapter.go @@ -297,12 +297,18 @@ func (a *Adapter) convertBladeToChunks(templates []BladeTemplate) []php.CodeChun }) } + 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{ diff --git a/pkg/parser/php/laravel/blade.go b/pkg/parser/php/laravel/blade.go index 9e2ba07..64824ce 100644 --- a/pkg/parser/php/laravel/blade.go +++ b/pkg/parser/php/laravel/blade.go @@ -13,7 +13,7 @@ import ( // Compiled regex patterns for Blade directives var ( reExtends = regexp.MustCompile(`@extends\(\s*['"](.+?)['"]\s*\)`) - reSection = regexp.MustCompile(`@section\(\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*\)`) @@ -128,6 +128,8 @@ func (ba *BladeAnalyzer) analyzeFile(filePath string) (BladeTemplate, error) { } } + tpl.TotalLines = lineNum + return tpl, scanner.Err() } diff --git a/pkg/parser/php/laravel/enricher.go b/pkg/parser/php/laravel/enricher.go index 7f48a09..bad867d 100644 --- a/pkg/parser/php/laravel/enricher.go +++ b/pkg/parser/php/laravel/enricher.go @@ -123,7 +123,7 @@ func (e *Enricher) Enrich(ca *php.CodeAnalyzer, packages []*php.PackageInfo, pat } } - logger.Instance.Debug("[LARAVEL] Enrich DONE: returning %d total chunks (before blade)", len(chunks)) + logger.Instance.Debug("[LARAVEL] Enrich: %d chunks after routes, before blade analysis", len(chunks)) // Analyze Blade Templates bladeFiles := e.adapter.findBladeFiles(paths) diff --git a/pkg/parser/php/laravel/types.go b/pkg/parser/php/laravel/types.go index 2b44429..83e2602 100644 --- a/pkg/parser/php/laravel/types.go +++ b/pkg/parser/php/laravel/types.go @@ -132,13 +132,14 @@ type Middleware struct { // 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"` - 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([...]) + 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 diff --git a/pkg/parser/php/wordpress/analyzer.go b/pkg/parser/php/wordpress/analyzer.go index 07d9602..142949d 100644 --- a/pkg/parser/php/wordpress/analyzer.go +++ b/pkg/parser/php/wordpress/analyzer.go @@ -80,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) @@ -149,6 +150,12 @@ 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 }) } @@ -173,8 +180,11 @@ func (a *Analyzer) analyzeWordPress(packages []*php.PackageInfo, paths []string) }) } wcHooks := a.woocommerceAnalyzer.AnalyzeHooksFromWP(wcInputHooks) - if len(wcHooks) > 0 { - wcInfo := &woocommerce.WooCommerceInfo{Hooks: wcHooks} + if len(wcHooks) > 0 || len(wcAPICalls) > 0 { + wcInfo := &woocommerce.WooCommerceInfo{ + Hooks: wcHooks, + APICalls: wcAPICalls, + } info.WooCommerceInfo = wcInfo } @@ -428,7 +438,7 @@ func (a *Analyzer) convertToChunks(info *WordPressInfo) []php.CodeChunk { FilePath: wcHook.FilePath, StartLine: wcHook.StartLine, EndLine: wcHook.EndLine, - Signature: fmt.Sprintf("%s('%s', '%s')", wcHook.HookType, wcHook.HookName, wcHook.Callback), + Signature: buildWCHookSignature(wcHook), Docstring: fmt.Sprintf("WooCommerce %s hook (%s area): %s", wcHook.HookType, wcHook.Area, wcHook.HookName), Metadata: map[string]any{ "framework": "wordpress", @@ -504,6 +514,33 @@ 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) + 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/woocommerce/analyzer.go b/pkg/parser/php/wordpress/woocommerce/analyzer.go index d7c5059..480216f 100644 --- a/pkg/parser/php/wordpress/woocommerce/analyzer.go +++ b/pkg/parser/php/wordpress/woocommerce/analyzer.go @@ -333,6 +333,8 @@ func extractExprValue(expr ast.Vertex) string { 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 { @@ -341,6 +343,14 @@ func extractExprValue(expr ast.Vertex) string { } } 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) From 9c34e578bd21825ddbcd2b812f436fdb95984d10 Mon Sep 17 00:00:00 2001 From: razvan Date: Tue, 17 Mar 2026 23:56:51 +0200 Subject: [PATCH 4/5] ci: update Go version from 1.22 to 1.24 to match go.mod go.mod requires Go 1.24.4 but CI workflows were using Go 1.22, causing 'no such file or directory' errors during test runs. Signed-off-by: doITmagic --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 From cea9f989ead75a40f8fa0202afcce67ed5a76bf1 Mon Sep 17 00:00:00 2001 From: razvan Date: Wed, 18 Mar 2026 00:29:53 +0200 Subject: [PATCH 5/5] fix: address PR #46 review round 2 (5 fixes) - Add NameFullyQualified support to woocommerce extractFuncName - Add action_check/filter_check cases to buildWCHookSignature - Use dynamic BaseClass for Oxygen element signatures - Increase bufio.Scanner buffer to 1MB for Blade templates - Update CI Go version from 1.22 to 1.24 to match go.mod Signed-off-by: doITmagic --- pkg/parser/php/laravel/blade.go | 1 + pkg/parser/php/wordpress/analyzer.go | 10 +++++++++- pkg/parser/php/wordpress/oxygen/analyzer.go | 13 +++++++++++++ pkg/parser/php/wordpress/oxygen/types.go | 5 +++-- pkg/parser/php/wordpress/woocommerce/analyzer.go | 8 ++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/pkg/parser/php/laravel/blade.go b/pkg/parser/php/laravel/blade.go index 64824ce..9969d3c 100644 --- a/pkg/parser/php/laravel/blade.go +++ b/pkg/parser/php/laravel/blade.go @@ -61,6 +61,7 @@ func (ba *BladeAnalyzer) analyzeFile(filePath string) (BladeTemplate, error) { } scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) // Allow lines up to 1MB lineNum := 0 for scanner.Scan() { lineNum++ diff --git a/pkg/parser/php/wordpress/analyzer.go b/pkg/parser/php/wordpress/analyzer.go index 142949d..917ab15 100644 --- a/pkg/parser/php/wordpress/analyzer.go +++ b/pkg/parser/php/wordpress/analyzer.go @@ -389,6 +389,10 @@ func (a *Analyzer) convertToChunks(info *WordPressInfo) []php.CodeChunk { 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", @@ -396,7 +400,7 @@ func (a *Analyzer) convertToChunks(info *WordPressInfo) []php.CodeChunk { FilePath: elem.FilePath, StartLine: elem.StartLine, EndLine: elem.EndLine, - Signature: fmt.Sprintf("class %s extends OxyEl", elem.ClassName), + 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", @@ -536,6 +540,10 @@ func buildWCHookSignature(wcHook woocommerce.WCHook) string { 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) } diff --git a/pkg/parser/php/wordpress/oxygen/analyzer.go b/pkg/parser/php/wordpress/oxygen/analyzer.go index fb0cf9b..7e46618 100644 --- a/pkg/parser/php/wordpress/oxygen/analyzer.go +++ b/pkg/parser/php/wordpress/oxygen/analyzer.go @@ -107,6 +107,7 @@ func (a *Analyzer) extractOxygenElement(class php.ClassInfo) OxygenElement { ClassName: class.Name, Namespace: class.Namespace, FullName: class.FullName, + BaseClass: extractBaseClassName(class.Extends), FilePath: class.FilePath, StartLine: class.StartLine, EndLine: class.EndLine, @@ -177,6 +178,18 @@ func (a *Analyzer) walkForOxygenTemplates(node ast.Vertex, filePath string, info } } +// extractBaseClassName extracts the short base class name from a possibly namespaced extends value +func extractBaseClassName(extends string) string { + if extends == "" { + return "" + } + // Get last segment after backslash (e.g., "Ns\OxyEl" → "OxyEl") + if idx := strings.LastIndex(extends, "\\"); idx >= 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/woocommerce/analyzer.go b/pkg/parser/php/wordpress/woocommerce/analyzer.go index 480216f..85056a3 100644 --- a/pkg/parser/php/wordpress/woocommerce/analyzer.go +++ b/pkg/parser/php/wordpress/woocommerce/analyzer.go @@ -287,6 +287,14 @@ func 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 "" }