1515 "try" , "throw" , "case" , "using" , "lock" , "foreach" , "select" , "func" ,
1616 "defer" , "go" , "range" , "elsif" , "unless" , "until" , "when" , "match" ,
1717 "sizeof" , "typeof" , "yield" , "puts" , "print" , "println" , "printf" ,
18+ "function" , "fn" , "use" , # PHP closures: `function () use ($x)`, `fn () =>`
1819}
1920
2021SQL_RE = re .compile (
2324 re .IGNORECASE )
2425CALL_RE = re .compile (r"(?<![\w$.:])([A-Za-z_][\w]*)\s*\(" )
2526
27+ # ---- PHP / Laravel ------------------------------------------------------
28+ # CALL_RE skips `X::f(` and would read `$obj->label(` as a call to any
29+ # function named label. In PHP only these member calls are trustworthy:
30+ PHP_MEMBER_CALL_RE = re .compile (
31+ r"(?:\$this->|\b(?:self|static|parent)::|\b[A-Z]\w*::)(\w+)\s*\(" )
32+ PHP_USE_RE = re .compile (r"^\s*use\s+([A-Za-z_][\w\\]*\\\w+)\s*(?:as\s+\w+\s*)?;" ,
33+ re .MULTILINE )
34+ PHP_VIEW_RE = re .compile (r"\bview\s*\(\s*['\"]([\w.\-/]+)['\"]" )
35+ # Eloquent: Booking::where(...), Booking::create(...), DB::table('bookings')
36+ PHP_ORM_RE = re .compile (
37+ r"\b([A-Z]\w*)::(query|where\w*|find\w*|first\w*|create|insert\w*|"
38+ r"update\w*|upsert|destroy|all|with|latest|oldest|orderBy|count|sum|"
39+ r"pluck|paginate|select)\s*\(" )
40+ PHP_DB_TABLE_RE = re .compile (r"\bDB::table\(\s*['\"](\w+)['\"]" )
41+ ORM_OP = {"create" : "insert" , "insert" : "insert" , "upsert" : "insert" ,
42+ "update" : "update" , "destroy" : "delete" }
43+ NOT_MODELS = {"DB" , "Route" , "Schema" , "Carbon" , "Str" , "Arr" , "Auth" , "Cache" ,
44+ "Log" , "Http" , "Storage" , "Validator" , "Hash" , "Session" , "Config" ,
45+ "Gate" , "Mail" , "Event" , "Queue" , "App" , "Request" , "Response" }
46+ SCHEMA_RE = re .compile (r"Schema::(create|table)\s*\(\s*['\"](\w+)['\"]" )
47+ BLUEPRINT_COL_RE = re .compile (r"\$\w+->(\w+)\s*\(\s*(?:['\"](\w+)['\"])?" )
48+ BLUEPRINT_IMPLIED = {"id" : ["id" ], "timestamps" : ["created_at" , "updated_at" ],
49+ "timestampsTz" : ["created_at" , "updated_at" ],
50+ "softDeletes" : ["deleted_at" ], "rememberToken" : ["remember_token" ]}
51+ BLUEPRINT_SKIP = {"index" , "unique" , "primary" , "foreign" , "dropColumn" ,
52+ "dropForeign" , "dropIndex" , "renameColumn" , "fullText" ,
53+ "spatialIndex" , "comment" , "engine" , "charset" , "collation" }
54+ ELOQUENT_RE = re .compile (
55+ r"^\s*(?:final\s+)?class\s+(\w+)\s+extends\s+(?:Model|Authenticatable|Pivot)\b" ,
56+ re .MULTILINE )
57+ PHP_TABLE_PROP_RE = re .compile (r"\$table\s*=\s*['\"](\w+)['\"]" )
58+
59+
60+ def _snake (name : str ) -> str :
61+ return re .sub (r"(?<=[a-z0-9])([A-Z])" , r"_\1" , name ).lower ()
62+
63+
64+ def _plural_candidates (snake : str ) -> List [str ]:
65+ """Laravel's table guess for a model, best first (Booking -> bookings)."""
66+ if snake .endswith ("y" ) and snake [- 2 :- 1 ] not in "aeiou" :
67+ first = snake [:- 1 ] + "ies"
68+ elif snake .endswith (("s" , "x" , "z" , "ch" , "sh" )):
69+ first = snake + "es"
70+ else :
71+ first = snake + "s"
72+ return [first , snake ] # bare name covers uncountables like `equipment`
73+
74+
75+ def _php_member_calls (body : str ) -> List [str ]:
76+ return [m .group (1 ) for m in PHP_MEMBER_CALL_RE .finditer (body )]
77+
78+
79+ def _php_db_refs (body : str , line : int ) -> List [DbRef ]:
80+ refs = [DbRef (table = m .group (1 ), op = "query" , via = "sql" , line = line )
81+ for m in PHP_DB_TABLE_RE .finditer (body )]
82+ for m in PHP_ORM_RE .finditer (body ):
83+ if m .group (1 ) in NOT_MODELS :
84+ continue
85+ op = next ((v for k , v in ORM_OP .items () if m .group (2 ).startswith (k )), "select" )
86+ refs .append (DbRef (table = m .group (1 ), op = op , via = "orm-guess" , line = line ))
87+ return refs
88+
89+
90+ def parse_laravel (info : FileInfo , text : str ) -> None :
91+ """File-level Laravel facts: `use` imports, view() links, migrations,
92+ Eloquent models. Cheap regex passes; nothing matches in non-Laravel PHP."""
93+ info .imports .extend (m .group (1 ).replace ("\\ " , "/" ) for m in PHP_USE_RE .finditer (text ))
94+ for m in PHP_VIEW_RE .finditer (text ):
95+ info .links .append ("resources/views/" + m .group (1 ).replace ("." , "/" ) + ".blade.php" )
96+
97+ for m in SCHEMA_RE .finditer (text ):
98+ end = _brace_end_line (text , m .end ())
99+ block = "\n " .join (text .split ("\n " )[_line_of (text , m .start ()) - 1 :end or None ])
100+ cols : List [str ] = []
101+ for c in BLUEPRINT_COL_RE .finditer (block ):
102+ method , col = c .group (1 ), c .group (2 )
103+ if method in BLUEPRINT_SKIP :
104+ continue
105+ for name in BLUEPRINT_IMPLIED .get (method , [col ] if col else []):
106+ if name not in cols :
107+ cols .append (name )
108+ # Schema::table() only alters: it adds columns but doesn't define the table
109+ info .tables .append (TableInfo (name = m .group (2 ), fields = cols [:40 ],
110+ line = _line_of (text , m .start ()),
111+ source = info .path if m .group (1 ) == "create" else "" ))
112+
113+ for m in ELOQUENT_RE .finditer (text ):
114+ cls = m .group (1 )
115+ explicit = PHP_TABLE_PROP_RE .search (text )
116+ cands = [explicit .group (1 )] if explicit else _plural_candidates (_snake (cls ))
117+ # no source: the migration defines the table; the model links to it by edge
118+ info .tables .append (TableInfo (name = cands [0 ], model = cls , aliases = cands ,
119+ line = _line_of (text , m .start ())))
120+
26121LANG_PATTERNS : Dict [str , Dict [str , Pattern ]] = {
27122 "go" : {
28123 "function" : re .compile (
@@ -173,8 +268,16 @@ def parse_generic(info: FileInfo, text: str, language: str) -> None:
173268 cname = cm .group (1 )
174269 if cname in KEYWORDS or cname == name or cname in seen :
175270 continue
271+ if language == "php" and body [max (0 , cm .start () - 2 ):cm .start ()] == "->" :
272+ continue # $obj->method(): unknown receiver, see PHP_MEMBER_CALL_RE
176273 seen .add (cname )
177274 fn .calls .append (cname )
275+ if language == "php" :
276+ for cname in _php_member_calls (body ):
277+ if cname not in seen and cname != name and cname not in KEYWORDS :
278+ seen .add (cname )
279+ fn .calls .append (cname )
280+ fn .db_refs .extend (_php_db_refs (body , start_line ))
178281 fn .calls = fn .calls [:40 ]
179282 fn .description = describe (fn , body [:400 ])
180283 info .functions .append (fn )
@@ -196,6 +299,10 @@ def parse_generic(info: FileInfo, text: str, language: str) -> None:
196299
197300 if language in ("php" , "ruby" ):
198301 extract_toplevel (info , text )
302+ if language == "php" :
303+ parse_laravel (info , text )
304+ info .imports = sorted (set (info .imports ))[:60 ]
305+ info .links = list (dict .fromkeys (info .links ))[:30 ]
199306
200307
201308PAGE_LINK_RE = re .compile (
@@ -218,14 +325,23 @@ def extract_toplevel(info: FileInfo, text: str) -> None:
218325 def outside (line : int ) -> bool :
219326 return not any (s <= line <= e for s , e in fn_ranges )
220327
328+ is_php = info .language == "php"
221329 seen = set ()
222330 for m in CALL_RE .finditer (text ):
223331 name = m .group (1 )
224332 if name in TOPLEVEL_NOISE or name in seen :
225333 continue
334+ if is_php and text [max (0 , m .start () - 2 ):m .start ()] == "->" :
335+ continue
226336 if outside (_line_of (text , m .start ())):
227337 seen .add (name )
228338 info .calls .append (name )
339+ if is_php : # routes/web.php closures call App\X::y() at top level
340+ for m in PHP_MEMBER_CALL_RE .finditer (text ):
341+ name = m .group (1 )
342+ if name not in seen and name not in TOPLEVEL_NOISE and outside (_line_of (text , m .start ())):
343+ seen .add (name )
344+ info .calls .append (name )
229345 info .calls = info .calls [:40 ]
230346
231347 seen_l = set ()
0 commit comments