Skip to content

Commit 1fcaca2

Browse files
authored
feat(wordpress,laravel): integrate Oxygen/WooCommerce analyzers and add Blade template analysis
## Description This PR delivers two independent but related features that extend the PHP parser pipeline with framework-specific enrichment: ### 1. WordPress: Oxygen Builder & WooCommerce Integration Integrate the existing Oxygen Builder and WooCommerce analyzers into the main WordPress parsing pipeline, so that plugin-specific patterns are automatically detected and indexed as enriched `CodeChunk` objects. Previously, `wordpress/oxygen/analyzer.go` and `wordpress/woocommerce/analyzer.go` existed with passing tests, but were **never imported** from the main `wordpress/analyzer.go`. This meant: - Oxygen `OxyEl` classes were parsed as generic PHP classes (no `oxy_element` metadata) - WooCommerce hooks like `woocommerce_before_cart` were detected as generic `wp_hook` (no area classification like `cart`, `checkout`, `product`) #### What this adds: - **Oxygen integration** — calls `oxygenAnalyzer.AnalyzeFromPackages()` in `analyzeWordPress()`: - `oxy_element` chunks for classes extending `OxyEl` / `OxyElShadow` / `OxygenElement` - `oxy_template` chunks for `ct_template` post type registrations - Rich metadata: `framework=wordpress`, `wp_type=oxygen_element`, namespace, methods, slug - **WooCommerce integration** — calls `woocommerceAnalyzer.AnalyzeHooksFromWP()`: - `wc_hook` chunks with area classification (`cart`, `checkout`, `product`, `order`, `payment`, etc.) - `wc_api_call` chunks for WC API functions (`wc_get_product`, `wc_get_order`, etc.) - Rich metadata: `wc_area`, `hook_type`, `callback`, `priority` - **Import cycle resolution** — `woocommerce` package previously imported `wordpress` (for `WPHook` type and `ASTHelper`), creating a cycle when `wordpress` imports `woocommerce`. Resolved by: - Defining `WPHookInput` struct locally in `woocommerce` package - Reimplementing AST helpers (`extractHookFromFunctionCall`, `extractCallArgs`, etc.) locally - Converting `WPHook → WPHookInput` at call site in `analyzer.go` - **3 new integration tests**: - `TestAnalyzer_OxygenElementDetection` — end-to-end OxyEl detection via `AnalyzePaths()` - `TestAnalyzer_WooCommerceHookClassification` — end-to-end WC area classification - `TestConvertToChunks_OxygenAndWooCommerce` — nil safety for new fields ### 2. Laravel: Blade Template Analysis Add a new Blade template analyzer to the Laravel enricher pipeline, enabling detection and indexing of `.blade.php` files as first-class `CodeChunk` objects. #### What this adds: - **BladeAnalyzer** (`laravel/blade.go`) — regex-based line-oriented parser that extracts: - `@extends`, `@section`, `@yield`, `@include`, `@component`, `@each`, `@push`/`@stack`, `@props` directives - View name resolution from file paths (dot notation) - Robust scanning with 1MB line buffer for minified templates - **Blade chunk conversion** (`laravel/adapter.go`) — converts `BladeTemplate` to `php.CodeChunk`: - `blade_template` type with metadata (`framework=laravel`, `blade=true`, sections/includes count) - Structural relations: `RelInheritance` for `@extends`, `RelDependency` for `@include`/`@component` - Proper `EndLine` set from total line count - **Blade file discovery** — recursive `.blade.php` file finder with vendor/node_modules exclusion - **Enricher integration** — Blade analysis runs as part of `EnrichChunks()` in the Laravel enricher - **Unit tests** (`laravel/blade_test.go`) — directive extraction and view name formatting ### Architecture decision: Both features are implemented as **direct integration** (analyzers called from their respective framework pipelines), not as separate enrichers, because: 1. They are conceptual extensions of their parent frameworks — they don't make sense outside WP/Laravel context 2. They reuse the same packages/AST already parsed by the parent analyzer 3. Avoids duplicating file walking and parsing ### Other changes: - `.gitignore` — ignores `docs/plans/*.md` - `.github/workflows/test.yml` / `release.yml` — updates CI Go version to 1.24 Closes: Trello cards #114-#119 ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update ## Checklist: - [x] I have performed a self-review of my own code - [x] I have formatted my code with `go fmt ./...` - [x] I have run tests `go test ./...` and they pass - [ ] I have verified integration with Ollama/Qdrant (if applicable) - [x] I have updated the documentation accordingly ## Files Changed | File | Change | |------|--------| | `pkg/parser/php/wordpress/types.go` | Added `OxygenInfo any` and `WooCommerceInfo any` fields to `WordPressInfo` | | `pkg/parser/php/wordpress/analyzer.go` | Imported `oxygen` + `woocommerce`, added analyzers to struct, integrated calls in `analyzeWordPress()` and `convertToChunks()` | | `pkg/parser/php/wordpress/analyzer_test.go` | +3 integration tests for Oxygen, WooCommerce, and nil safety | | `pkg/parser/php/wordpress/woocommerce/analyzer.go` | Broke import cycle: removed `wordpress` import, defined `WPHookInput`, reimplemented AST helpers locally | | `pkg/parser/php/wordpress/woocommerce/analyzer_test.go` | Updated `TestAnalyzeHooksFromWP` to use `WPHookInput` instead of `wordpress.WPHook` | | `pkg/parser/php/laravel/blade.go` | New Blade template analyzer with regex-based directive extraction | | `pkg/parser/php/laravel/blade_test.go` | Unit tests for Blade directive extraction and view name formatting | | `pkg/parser/php/laravel/adapter.go` | Blade file discovery + template→chunk conversion with relations | | `pkg/parser/php/laravel/types.go` | Added `BladeTemplates` to `LaravelInfo` and Blade template structs | | `pkg/parser/php/laravel/enricher.go` | Runs Blade analysis during Laravel enrichment | | `.gitignore` | Ignores `docs/plans/*.md` | | `.github/workflows/test.yml` | Updates CI Go version to 1.24 | | `.github/workflows/release.yml` | Updates release workflow Go version to 1.24 |
2 parents ae547cd + cea9f98 commit 1fcaca2

15 files changed

Lines changed: 1112 additions & 59 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
- name: Set up Go
4141
uses: actions/setup-go@v5
4242
with:
43-
go-version: '1.22'
43+
go-version: '1.24'
4444
cache: true
4545

4646
- name: Run GoReleaser

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
- name: Set up Go
1919
uses: actions/setup-go@v5
2020
with:
21-
go-version: '1.22'
21+
go-version: '1.24'
2222
cache: true
2323

2424
- name: Download dependencies
@@ -54,7 +54,7 @@
5454
- name: Set up Go
5555
uses: actions/setup-go@v5
5656
with:
57-
go-version: '1.22'
57+
go-version: '1.24'
5858
cache: true
5959

6060
- name: golangci-lint

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Thumbs.db
6060
# Temporary files
6161
tmp/
6262
temp/
63+
docs/plans/*.md
6364

6465
# Scripts
6566
scripts/

pkg/parser/php/laravel/adapter.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package laravel
33
import (
44
"fmt"
55
"io/fs"
6+
"os"
67
"path/filepath"
78
"strings"
89

@@ -199,3 +200,135 @@ func (a *Adapter) convertRoutesToChunks(routes []Route) []php.CodeChunk {
199200

200201
return chunks
201202
}
203+
204+
// findBladeFiles searches recursively for .blade.php files in the given paths.
205+
// Skips vendor/, node_modules/, .git/, and hidden directories.
206+
func (a *Adapter) findBladeFiles(paths []string) []string {
207+
var bladeFiles []string
208+
209+
for _, root := range paths {
210+
info, err := os.Stat(root)
211+
if err != nil {
212+
continue
213+
}
214+
215+
// Single file check
216+
if !info.IsDir() {
217+
if strings.HasSuffix(root, ".blade.php") {
218+
bladeFiles = append(bladeFiles, root)
219+
}
220+
continue
221+
}
222+
223+
// Walk directory
224+
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
225+
if err != nil {
226+
return nil
227+
}
228+
if d.IsDir() {
229+
base := d.Name()
230+
if base == "vendor" || base == "node_modules" || base == ".git" ||
231+
strings.HasPrefix(base, ".") {
232+
if path != root {
233+
return filepath.SkipDir
234+
}
235+
}
236+
return nil
237+
}
238+
if strings.HasSuffix(d.Name(), ".blade.php") {
239+
bladeFiles = append(bladeFiles, path)
240+
}
241+
return nil
242+
})
243+
}
244+
245+
return bladeFiles
246+
}
247+
248+
// convertBladeToChunks converts BladeTemplate structs to php.CodeChunk with
249+
// metadata and structural relations (inheritance for @extends, dependency for @include/@component).
250+
func (a *Adapter) convertBladeToChunks(templates []BladeTemplate) []php.CodeChunk {
251+
var chunks []php.CodeChunk
252+
253+
for _, tpl := range templates {
254+
// Build signature
255+
sig := tpl.Name
256+
if tpl.Extends != "" {
257+
sig = fmt.Sprintf("@extends('%s')", tpl.Extends)
258+
}
259+
260+
// Build docstring from directives summary
261+
var docParts []string
262+
if tpl.Extends != "" {
263+
docParts = append(docParts, fmt.Sprintf("Extends: %s", tpl.Extends))
264+
}
265+
if len(tpl.Sections) > 0 {
266+
names := make([]string, len(tpl.Sections))
267+
for i, s := range tpl.Sections {
268+
names[i] = s.Name
269+
}
270+
docParts = append(docParts, fmt.Sprintf("Sections: %s", strings.Join(names, ", ")))
271+
}
272+
if len(tpl.Includes) > 0 {
273+
names := make([]string, len(tpl.Includes))
274+
for i, inc := range tpl.Includes {
275+
names[i] = inc.ViewName
276+
}
277+
docParts = append(docParts, fmt.Sprintf("Includes: %s", strings.Join(names, ", ")))
278+
}
279+
if len(tpl.Props) > 0 {
280+
docParts = append(docParts, fmt.Sprintf("Props: %s", strings.Join(tpl.Props, ", ")))
281+
}
282+
283+
docstring := strings.Join(docParts, " | ")
284+
285+
// Build relations
286+
var relations []pkgParser.Relation
287+
if tpl.Extends != "" {
288+
relations = append(relations, pkgParser.Relation{
289+
TargetName: tpl.Extends,
290+
Type: pkgParser.RelInheritance,
291+
})
292+
}
293+
for _, inc := range tpl.Includes {
294+
relations = append(relations, pkgParser.Relation{
295+
TargetName: inc.ViewName,
296+
Type: pkgParser.RelDependency,
297+
})
298+
}
299+
300+
endLine := tpl.TotalLines
301+
if endLine < 1 {
302+
endLine = 1
303+
}
304+
305+
chunk := php.CodeChunk{
306+
Name: tpl.Name,
307+
Type: "blade_template",
308+
Language: "php",
309+
FilePath: tpl.FilePath,
310+
StartLine: 1,
311+
EndLine: endLine,
312+
Signature: sig,
313+
Docstring: docstring,
314+
Metadata: map[string]any{
315+
"framework": "laravel",
316+
"blade": true,
317+
"sections_count": len(tpl.Sections),
318+
"includes_count": len(tpl.Includes),
319+
},
320+
Relations: relations,
321+
}
322+
323+
if len(tpl.Stacks) > 0 {
324+
chunk.Metadata["stacks"] = tpl.Stacks
325+
}
326+
if len(tpl.Props) > 0 {
327+
chunk.Metadata["props"] = tpl.Props
328+
}
329+
330+
chunks = append(chunks, chunk)
331+
}
332+
333+
return chunks
334+
}

pkg/parser/php/laravel/blade.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package laravel
2+
3+
import (
4+
"bufio"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/doITmagic/rag-code-mcp/internal/logger"
11+
)
12+
13+
// Compiled regex patterns for Blade directives
14+
var (
15+
reExtends = regexp.MustCompile(`@extends\(\s*['"](.+?)['"]\s*\)`)
16+
reSection = regexp.MustCompile(`@section\(\s*['"](.+?)['"]\s*(?:,.*?)?\)`)
17+
reYield = regexp.MustCompile(`@yield\(\s*['"](.+?)['"]\s*\)`)
18+
reInclude = regexp.MustCompile(`@include\(\s*['"](.+?)['"]\s*\)`)
19+
reComponent = regexp.MustCompile(`@component\(\s*['"](.+?)['"]\s*\)`)
20+
reEach = regexp.MustCompile(`@each\(\s*['"](.+?)['"]\s*\)`)
21+
rePushStack = regexp.MustCompile(`@(?:push|stack)\(\s*['"](.+?)['"]\s*\)`)
22+
reProps = regexp.MustCompile(`@props\(\s*\[(.*?)\]\s*\)`)
23+
)
24+
25+
// BladeAnalyzer parses Blade template files and extracts directives.
26+
type BladeAnalyzer struct{}
27+
28+
// NewBladeAnalyzer creates a new BladeAnalyzer.
29+
func NewBladeAnalyzer() *BladeAnalyzer {
30+
return &BladeAnalyzer{}
31+
}
32+
33+
// Analyze parses the given Blade template files, extracting directives.
34+
// Files that cannot be read are logged and skipped (no error returned).
35+
func (ba *BladeAnalyzer) Analyze(filePaths []string) []BladeTemplate {
36+
var templates []BladeTemplate
37+
38+
for _, fp := range filePaths {
39+
tpl, err := ba.analyzeFile(fp)
40+
if err != nil {
41+
logger.Instance.Debug("[BLADE] skip %s: %v", filepath.Base(fp), err)
42+
continue
43+
}
44+
templates = append(templates, tpl)
45+
}
46+
47+
return templates
48+
}
49+
50+
// analyzeFile parses a single Blade file.
51+
func (ba *BladeAnalyzer) analyzeFile(filePath string) (BladeTemplate, error) {
52+
f, err := os.Open(filePath)
53+
if err != nil {
54+
return BladeTemplate{}, err
55+
}
56+
defer f.Close()
57+
58+
tpl := BladeTemplate{
59+
Name: bladeViewName(filePath),
60+
FilePath: filePath,
61+
}
62+
63+
scanner := bufio.NewScanner(f)
64+
scanner.Buffer(make([]byte, 64*1024), 1024*1024) // Allow lines up to 1MB
65+
lineNum := 0
66+
for scanner.Scan() {
67+
lineNum++
68+
line := scanner.Text()
69+
70+
// @extends
71+
if m := reExtends.FindStringSubmatch(line); len(m) > 1 {
72+
tpl.Extends = m[1]
73+
}
74+
75+
// @section
76+
if m := reSection.FindStringSubmatch(line); len(m) > 1 {
77+
tpl.Sections = append(tpl.Sections, BladeSection{
78+
Name: m[1],
79+
Type: "section",
80+
StartLine: lineNum,
81+
})
82+
}
83+
84+
// @yield
85+
if m := reYield.FindStringSubmatch(line); len(m) > 1 {
86+
tpl.Sections = append(tpl.Sections, BladeSection{
87+
Name: m[1],
88+
Type: "yield",
89+
StartLine: lineNum,
90+
})
91+
}
92+
93+
// @include
94+
if m := reInclude.FindStringSubmatch(line); len(m) > 1 {
95+
tpl.Includes = append(tpl.Includes, BladeInclude{
96+
ViewName: m[1],
97+
Type: "include",
98+
Line: lineNum,
99+
})
100+
}
101+
102+
// @component
103+
if m := reComponent.FindStringSubmatch(line); len(m) > 1 {
104+
tpl.Includes = append(tpl.Includes, BladeInclude{
105+
ViewName: m[1],
106+
Type: "component",
107+
Line: lineNum,
108+
})
109+
}
110+
111+
// @each
112+
if m := reEach.FindStringSubmatch(line); len(m) > 1 {
113+
tpl.Includes = append(tpl.Includes, BladeInclude{
114+
ViewName: m[1],
115+
Type: "each",
116+
Line: lineNum,
117+
})
118+
}
119+
120+
// @push / @stack
121+
if m := rePushStack.FindStringSubmatch(line); len(m) > 1 {
122+
tpl.Stacks = appendUnique(tpl.Stacks, m[1])
123+
}
124+
125+
// @props
126+
if m := reProps.FindStringSubmatch(line); len(m) > 1 {
127+
props := parsePropsArray(m[1])
128+
tpl.Props = append(tpl.Props, props...)
129+
}
130+
}
131+
132+
tpl.TotalLines = lineNum
133+
134+
return tpl, scanner.Err()
135+
}
136+
137+
// bladeViewName converts a file path to Laravel dot notation.
138+
// Example: /project/resources/views/layouts/app.blade.php → layouts.app
139+
func bladeViewName(filePath string) string {
140+
// Normalize to forward slashes
141+
fp := filepath.ToSlash(filePath)
142+
143+
// Try to find resources/views/ in the path
144+
marker := "resources/views/"
145+
idx := strings.LastIndex(fp, marker)
146+
if idx >= 0 {
147+
relative := fp[idx+len(marker):]
148+
// Remove .blade.php extension
149+
relative = strings.TrimSuffix(relative, ".blade.php")
150+
return strings.ReplaceAll(relative, "/", ".")
151+
}
152+
153+
// Fallback: use basename without extension
154+
base := filepath.Base(filePath)
155+
return strings.TrimSuffix(base, ".blade.php")
156+
}
157+
158+
// parsePropsArray extracts prop names from a @props([...]) content string.
159+
// Input: "'title', 'color'" → Output: ["title", "color"]
160+
func parsePropsArray(raw string) []string {
161+
var props []string
162+
parts := strings.Split(raw, ",")
163+
for _, p := range parts {
164+
p = strings.TrimSpace(p)
165+
p = strings.Trim(p, "'\"")
166+
if p != "" {
167+
props = append(props, p)
168+
}
169+
}
170+
return props
171+
}
172+
173+
// appendUnique appends s to slice only if not already present.
174+
func appendUnique(slice []string, s string) []string {
175+
for _, existing := range slice {
176+
if existing == s {
177+
return slice
178+
}
179+
}
180+
return append(slice, s)
181+
}

0 commit comments

Comments
 (0)