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}