Skip to main content

lean_ctx/core/patterns/
php.rs

1use regex::Regex;
2use std::sync::OnceLock;
3
4static CLASS_RE: OnceLock<Regex> = OnceLock::new();
5static METHOD_RE: OnceLock<Regex> = OnceLock::new();
6static USE_RE: OnceLock<Regex> = OnceLock::new();
7static EXTENDS_RE: OnceLock<Regex> = OnceLock::new();
8static RELATION_RE: OnceLock<Regex> = OnceLock::new();
9static FILLABLE_RE: OnceLock<Regex> = OnceLock::new();
10static CASTS_RE: OnceLock<Regex> = OnceLock::new();
11static SCOPE_RE: OnceLock<Regex> = OnceLock::new();
12static MIGRATION_COL_RE: OnceLock<Regex> = OnceLock::new();
13static MIGRATION_TABLE_RE: OnceLock<Regex> = OnceLock::new();
14static BLADE_DIRECTIVE_RE: OnceLock<Regex> = OnceLock::new();
15
16fn class_re() -> &'static Regex {
17    CLASS_RE.get_or_init(|| {
18        Regex::new(r"(?:abstract\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?(?:\s+implements\s+([\w,\s\\]+))?").unwrap()
19    })
20}
21fn method_re() -> &'static Regex {
22    METHOD_RE.get_or_init(|| {
23        Regex::new(r"(?:public|protected|private)\s+(?:static\s+)?function\s+(\w+)\s*\(([^)]*)\)(?:\s*:\s*(\S+))?").unwrap()
24    })
25}
26fn use_re() -> &'static Regex {
27    USE_RE.get_or_init(|| Regex::new(r"^use\s+([\w\\]+)(?:\s+as\s+(\w+))?\s*;").unwrap())
28}
29fn extends_re() -> &'static Regex {
30    EXTENDS_RE.get_or_init(|| Regex::new(r"extends\s+([\w\\]+)").unwrap())
31}
32fn relation_re() -> &'static Regex {
33    RELATION_RE.get_or_init(|| {
34        Regex::new(r"\$this->(hasMany|hasOne|belongsTo|belongsToMany|morphMany|morphTo|morphOne|hasManyThrough|hasOneThrough)\s*\(\s*(\w+)::class").unwrap()
35    })
36}
37fn fillable_re() -> &'static Regex {
38    FILLABLE_RE.get_or_init(|| Regex::new(r#"\$fillable\s*=\s*\[([\s\S]*?)\]"#).unwrap())
39}
40fn casts_re() -> &'static Regex {
41    CASTS_RE.get_or_init(|| Regex::new(r#"\$casts\s*=\s*\[([\s\S]*?)\]"#).unwrap())
42}
43fn scope_re() -> &'static Regex {
44    SCOPE_RE.get_or_init(|| Regex::new(r"public\s+function\s+(scope\w+)\s*\(").unwrap())
45}
46fn migration_col_re() -> &'static Regex {
47    MIGRATION_COL_RE.get_or_init(|| {
48        Regex::new(r#"\$table->(\w+)\s*\(\s*'(\w+)'(?:\s*,\s*(\d+))?\s*\)"#).unwrap()
49    })
50}
51fn migration_table_re() -> &'static Regex {
52    MIGRATION_TABLE_RE.get_or_init(|| Regex::new(r#"Schema::create\s*\(\s*'(\w+)'"#).unwrap())
53}
54fn blade_directive_re() -> &'static Regex {
55    BLADE_DIRECTIVE_RE.get_or_init(|| {
56        Regex::new(r"@(extends|section|yield|component|include|foreach|if|auth|guest|can|slot|push|stack|livewire|props)\s*\(([^)]*)\)").unwrap()
57    })
58}
59
60pub fn compress_php_map(content: &str, filename: &str) -> Option<String> {
61    if filename.ends_with(".blade.php") {
62        return Some(compress_blade(content));
63    }
64
65    let parent = detect_laravel_type(content);
66    match parent.as_deref() {
67        Some("Model") => Some(compress_eloquent(content)),
68        Some("Controller") => Some(compress_controller(content)),
69        Some("Migration") if content.contains("Schema::") => Some(compress_migration(content)),
70        Some("Job") | Some("Event") | Some("Listener") | Some("Notification") | Some("Mail")
71        | Some("Policy") | Some("Request") => {
72            Some(compress_service_class(content, parent.as_deref().unwrap()))
73        }
74        _ => None,
75    }
76}
77
78fn detect_laravel_type(content: &str) -> Option<String> {
79    if let Some(caps) = extends_re().captures(content) {
80        let parent = caps[1].rsplit('\\').next().unwrap_or(&caps[1]);
81        return match parent {
82            "Model" | "Authenticatable" | "Pivot" => Some("Model".to_string()),
83            "Controller" => Some("Controller".to_string()),
84            "Migration" => Some("Migration".to_string()),
85            "Job" | "ShouldQueue" => Some("Job".to_string()),
86            "Event" => Some("Event".to_string()),
87            "Listener" | "ShouldHandleEventsAfterCommit" => Some("Listener".to_string()),
88            "Notification" => Some("Notification".to_string()),
89            "Mailable" | "Mail" => Some("Mail".to_string()),
90            "Policy" => Some("Policy".to_string()),
91            "FormRequest" => Some("Request".to_string()),
92            _ => {
93                if content.contains("Schema::create") || content.contains("Schema::table") {
94                    Some("Migration".to_string())
95                } else {
96                    Some(parent.to_string())
97                }
98            }
99        };
100    }
101    if content.contains("Schema::create") || content.contains("Schema::table") {
102        return Some("Migration".to_string());
103    }
104    None
105}
106
107fn compress_eloquent(content: &str) -> String {
108    let mut parts = Vec::new();
109
110    if let Some(caps) = class_re().captures(content) {
111        parts.push(format!("§ {} extends Model", &caps[1]));
112    }
113
114    let imports: Vec<String> = use_re()
115        .captures_iter(content)
116        .map(|c| c[1].rsplit('\\').next().unwrap_or(&c[1]).to_string())
117        .collect();
118    if !imports.is_empty() {
119        parts.push(format!("  deps: {}", imports.join(", ")));
120    }
121
122    if let Some(caps) = fillable_re().captures(content) {
123        let fields = extract_quoted_strings(&caps[1]);
124        if !fields.is_empty() {
125            parts.push(format!("  fillable: {}", fields.join(", ")));
126        }
127    }
128
129    if let Some(caps) = casts_re().captures(content) {
130        let casts = extract_cast_pairs(&caps[1]);
131        if !casts.is_empty() {
132            parts.push(format!("  casts: {}", casts.join(", ")));
133        }
134    }
135
136    let relations: Vec<String> = relation_re()
137        .captures_iter(content)
138        .map(|c| format!("{}({})", &c[1], &c[2]))
139        .collect();
140    if !relations.is_empty() {
141        parts.push(format!("  relations: {}", relations.join(", ")));
142    }
143
144    let scopes: Vec<String> = scope_re()
145        .captures_iter(content)
146        .map(|c| c[1].strip_prefix("scope").unwrap_or(&c[1]).to_string())
147        .collect();
148    if !scopes.is_empty() {
149        parts.push(format!("  scopes: {}", scopes.join(", ")));
150    }
151
152    let methods: Vec<String> = method_re()
153        .captures_iter(content)
154        .filter(|c| !c[1].starts_with("scope"))
155        .map(|c| {
156            let ret = c.get(3).map(|m| m.as_str()).unwrap_or("");
157            if ret.is_empty() {
158                c[1].to_string()
159            } else {
160                format!("{}→{}", &c[1], ret)
161            }
162        })
163        .collect();
164    if !methods.is_empty() {
165        parts.push(format!("  methods: {}", methods.join(", ")));
166    }
167
168    parts.join("\n")
169}
170
171fn compress_controller(content: &str) -> String {
172    let mut parts = Vec::new();
173
174    if let Some(caps) = class_re().captures(content) {
175        parts.push(format!("§ {}", &caps[1]));
176    }
177
178    let methods: Vec<String> = method_re()
179        .captures_iter(content)
180        .map(|c| {
181            let name = &c[1];
182            let params = compact_params(&c[2]);
183            let ret = c.get(3).map(|m| m.as_str()).unwrap_or("");
184            if ret.is_empty() {
185                format!("  λ {}({})", name, params)
186            } else {
187                format!("  λ {}({})→{}", name, params, ret)
188            }
189        })
190        .collect();
191    parts.extend(methods);
192
193    parts.join("\n")
194}
195
196fn compress_migration(content: &str) -> String {
197    let mut parts = Vec::new();
198
199    let tables: Vec<String> = migration_table_re()
200        .captures_iter(content)
201        .map(|c| c[1].to_string())
202        .collect();
203
204    for table in &tables {
205        parts.push(format!("+{} table:", table));
206    }
207
208    let columns: Vec<String> = migration_col_re()
209        .captures_iter(content)
210        .filter_map(|c| {
211            let col_type = &c[1];
212            let col_name = &c[2];
213            if col_type == "table" || col_type == "create" {
214                return None;
215            }
216            let short_type = shorten_column_type(col_type);
217            Some(format!("  {}:{}", col_name, short_type))
218        })
219        .collect();
220    parts.extend(columns);
221
222    let has_timestamps = content.contains("$table->timestamps()");
223    let has_soft_deletes = content.contains("softDeletes");
224    if has_timestamps {
225        parts.push("  timestamps".to_string());
226    }
227    if has_soft_deletes {
228        parts.push("  softDeletes".to_string());
229    }
230
231    if parts.is_empty() {
232        return "migration (empty)".to_string();
233    }
234    parts.join("\n")
235}
236
237fn compress_service_class(content: &str, kind: &str) -> String {
238    let mut parts = Vec::new();
239
240    if let Some(caps) = class_re().captures(content) {
241        parts.push(format!("§ {} [{}]", &caps[1], kind));
242    }
243
244    let constructor: Vec<String> = content
245        .lines()
246        .filter(|l| {
247            let t = l.trim();
248            t.contains("public function __construct") || (t.contains("private ") && t.contains("$"))
249        })
250        .take(1)
251        .flat_map(|l| {
252            if let Some(caps) = method_re().captures(l) {
253                vec![format!("  __construct({})", compact_params(&caps[2]))]
254            } else {
255                vec![]
256            }
257        })
258        .collect();
259    parts.extend(constructor);
260
261    let methods: Vec<String> = method_re()
262        .captures_iter(content)
263        .filter(|c| &c[1] != "__construct")
264        .map(|c| {
265            let ret = c.get(3).map(|m| m.as_str()).unwrap_or("");
266            if ret.is_empty() {
267                format!("  λ {}", &c[1])
268            } else {
269                format!("  λ {}→{}", &c[1], ret)
270            }
271        })
272        .collect();
273    parts.extend(methods);
274
275    parts.join("\n")
276}
277
278fn compress_blade(content: &str) -> String {
279    let mut parts = Vec::new();
280
281    let directives: Vec<String> = blade_directive_re()
282        .captures_iter(content)
283        .map(|c| {
284            let dir = &c[1];
285            let arg = c[2].trim().trim_matches('\'').trim_matches('"');
286            format!("@{}({})", dir, arg)
287        })
288        .collect();
289
290    if directives.is_empty() {
291        let line_count = content.lines().count();
292        return format!("blade template ({line_count}L, no directives)");
293    }
294
295    let mut seen = std::collections::HashSet::new();
296    for d in &directives {
297        if seen.insert(d.clone()) {
298            parts.push(d.clone());
299        }
300    }
301
302    parts.join("\n")
303}
304
305fn compact_params(params: &str) -> String {
306    params
307        .split(',')
308        .map(|p| {
309            let p = p.trim();
310            if let Some(var) = p.rsplit_once(' ') {
311                var.1.to_string()
312            } else {
313                p.to_string()
314            }
315        })
316        .collect::<Vec<_>>()
317        .join(", ")
318}
319
320fn extract_quoted_strings(text: &str) -> Vec<String> {
321    let re = Regex::new(r"'(\w+)'").unwrap();
322    re.captures_iter(text).map(|c| c[1].to_string()).collect()
323}
324
325fn extract_cast_pairs(text: &str) -> Vec<String> {
326    let re = Regex::new(r"'(\w+)'\s*=>\s*'(\w+)'").unwrap();
327    re.captures_iter(text)
328        .map(|c| format!("{}:{}", &c[1], &c[2]))
329        .collect()
330}
331
332fn shorten_column_type(t: &str) -> &str {
333    match t {
334        "string" => "str",
335        "integer" => "int",
336        "bigInteger" | "unsignedBigInteger" => "bigint",
337        "boolean" => "bool",
338        "timestamp" | "timestampTz" => "ts",
339        "nullableTimestamps" => "ts?",
340        "text" => "text",
341        "json" | "jsonb" => "json",
342        "decimal" | "float" | "double" => "num",
343        "foreignId" | "foreignIdFor" => "fk",
344        "uuid" => "uuid",
345        "enum" => "enum",
346        "date" => "date",
347        "dateTime" | "dateTimeTz" => "datetime",
348        "id" => "id",
349        _ => t,
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn eloquent_model_compression() {
359        let model = r#"<?php
360namespace App\Models;
361
362use Illuminate\Database\Eloquent\Model;
363use Illuminate\Database\Eloquent\Relations\HasMany;
364
365class User extends Model
366{
367    protected $fillable = ['name', 'email', 'password'];
368    protected $casts = ['verified_at' => 'datetime', 'is_admin' => 'boolean'];
369
370    public function posts(): HasMany
371    {
372        return $this->hasMany(Post::class);
373    }
374
375    public function scopeActive($query)
376    {
377        return $query->where('active', true);
378    }
379
380    public function getFullNameAttribute(): string
381    {
382        return $this->first_name . ' ' . $this->last_name;
383    }
384}
385"#;
386        let result = compress_eloquent(model);
387        assert!(result.contains("User extends Model"), "class header");
388        assert!(
389            result.contains("fillable: name, email, password"),
390            "fillable"
391        );
392        assert!(result.contains("verified_at:datetime"), "casts");
393        assert!(result.contains("hasMany(Post)"), "relations");
394        assert!(result.contains("scopes: Active"), "scopes");
395    }
396
397    #[test]
398    fn controller_compression() {
399        let ctrl = r#"<?php
400namespace App\Http\Controllers;
401
402class UserController extends Controller
403{
404    public function index(): View
405    {
406        return view('users.index');
407    }
408
409    public function store(StoreUserRequest $request): RedirectResponse
410    {
411        User::create($request->validated());
412        return redirect()->route('users.index');
413    }
414
415    public function show(User $user): View
416    {
417        return view('users.show', compact('user'));
418    }
419}
420"#;
421        let result = compress_controller(ctrl);
422        assert!(result.contains("UserController"), "class name");
423        assert!(result.contains("λ index"), "index method");
424        assert!(result.contains("λ store"), "store method");
425        assert!(result.contains("λ show"), "show method");
426    }
427
428    #[test]
429    fn migration_compression() {
430        let migration = r#"<?php
431use Illuminate\Database\Migrations\Migration;
432use Illuminate\Database\Schema\Blueprint;
433
434return new class extends Migration
435{
436    public function up(): void
437    {
438        Schema::create('users', function (Blueprint $table) {
439            $table->id('id');
440            $table->string('name');
441            $table->string('email', 255);
442            $table->timestamp('verified_at');
443            $table->boolean('is_admin');
444            $table->timestamps();
445        });
446    }
447};
448"#;
449        let result = compress_migration(migration);
450        assert!(result.contains("+users table:"), "table name");
451        assert!(result.contains("name:str"), "string column");
452        assert!(result.contains("email:str"), "string with length");
453        assert!(result.contains("is_admin:bool"), "boolean");
454        assert!(result.contains("timestamps"), "timestamps");
455    }
456
457    #[test]
458    fn blade_template_compression() {
459        let blade = r#"
460@extends('layouts.app')
461
462@section('content')
463<div class="container mx-auto px-4 py-8">
464    <h1 class="text-2xl font-bold mb-4">Users</h1>
465    @foreach($users as $user)
466        <div class="card">
467            @include('partials.user-card')
468        </div>
469    @endforeach
470
471    @if(auth()->check())
472        @component('components.admin-panel')
473            Admin content here
474        @endcomponent
475    @endif
476</div>
477@endsection
478"#;
479        let result = compress_blade(blade);
480        assert!(result.contains("@extends(layouts.app)"), "extends");
481        assert!(result.contains("@section(content)"), "section");
482        assert!(result.contains("@foreach"), "foreach");
483        assert!(result.contains("@include"), "include");
484        assert!(!result.contains("<div"), "no raw HTML");
485    }
486
487    #[test]
488    fn service_class_compression() {
489        let job = r#"<?php
490namespace App\Jobs;
491
492class SendWelcomeEmail extends Job implements ShouldQueue
493{
494    public function __construct(
495        private User $user,
496        private string $template
497    ) {}
498
499    public function handle(Mailer $mailer): void
500    {
501        $mailer->send($this->template, $this->user);
502    }
503
504    public function failed(\Throwable $e): void
505    {
506        Log::error($e->getMessage());
507    }
508}
509"#;
510        let result = compress_service_class(job, "Job");
511        assert!(result.contains("SendWelcomeEmail [Job]"), "class + kind");
512        assert!(result.contains("λ handle"), "handle method");
513        assert!(result.contains("λ failed"), "failed method");
514    }
515
516    #[test]
517    fn detect_laravel_types() {
518        assert_eq!(
519            detect_laravel_type("class User extends Model {"),
520            Some("Model".to_string())
521        );
522        assert_eq!(
523            detect_laravel_type("class UserController extends Controller {"),
524            Some("Controller".to_string())
525        );
526        assert_eq!(
527            detect_laravel_type("Schema::create('users', function"),
528            Some("Migration".to_string())
529        );
530    }
531}