Skip to main content

lean_ctx/core/patterns/
php.rs

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