Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Thumbs.db
# Temporary files
tmp/
temp/
docs/plans/*.md

# Scripts
scripts/
Expand Down
133 changes: 133 additions & 0 deletions pkg/parser/php/laravel/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package laravel
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"

Expand Down Expand Up @@ -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
}
181 changes: 181 additions & 0 deletions pkg/parser/php/laravel/blade.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading