Skip to main content

lean_ctx_patterns/
php.rs

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