Skip to content

Commit bf2a31a

Browse files
committed
feat: enhance PHP support with Laravel migration parsing, model resolution, and nested .gitignore handling
1 parent 6af9600 commit bf2a31a

7 files changed

Lines changed: 302 additions & 38 deletions

File tree

‎codebread/connections.py‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@
2626
ENTRY_NAMES = {
2727
"main", "__init__", "__main__", "__new__", "__call__", "setup", "setUp",
2828
"tearDown", "run", "handler", "index", "render", "constructor",
29+
# PHP magic methods
30+
"__construct", "__destruct", "__invoke", "__get", "__set", "__toString",
31+
# Laravel: migrations, providers, jobs, requests, factories, policies
32+
"up", "down", "boot", "booted", "register", "handle", "schedule", "rules",
33+
"authorize", "definition", "casts", "toArray", "via", "toMail", "broadcastOn",
34+
"store", "show", "create", "edit", "update", "destroy",
35+
# Filament / Livewire hooks
36+
"form", "table", "configure", "panel", "mount", "getPages", "getRelations",
37+
"getHeaderActions", "getStats", "getData", "getType", "getEloquentQuery",
38+
"getGloballySearchableAttributes", "canAccessPanel",
2939
}
3040

3141

@@ -80,6 +90,10 @@ def _resolve_file_ref(base_file: str, target: str, file_set) -> Optional[str]:
8090
posixpath.normpath(target.lstrip("/")),
8191
target.lstrip("./"),
8292
]
93+
# PSR-4 namespaces: `use App\Support\MjIcon` lives in app/Support/MjIcon.php
94+
head, _, rest = target.partition("/")
95+
if rest and head[:1].isupper():
96+
candidates.append(head.lower() + "/" + rest)
8397
# python dotted modules: models / app.utils -> models.py / app/utils.py
8498
if "/" not in target and "." not in posixpath.basename(target):
8599
candidates.append(target.replace(".", "/"))
@@ -233,8 +247,9 @@ def table_node(t_name: str, t: Optional[TableInfo] = None) -> str:
233247
if t is not None: # enrich a placeholder with real definition
234248
for n in nodes:
235249
if n["id"] == nid:
236-
if t.fields:
237-
n["fields"] = t.fields
250+
if t.fields: # merge: migrations add columns over time
251+
n["fields"] = n.get("fields", []) + [
252+
c for c in t.fields if c not in n.get("fields", [])]
238253
if t.model:
239254
n["model"] = t.model
240255
if t.source:
@@ -252,11 +267,17 @@ def table_node(t_name: str, t: Optional[TableInfo] = None) -> str:
252267
})
253268
return nid
254269

270+
# real definitions first, so a model's guessed name can snap to one
271+
defined = {t.name.lower() for f in files for t in f.tables if not t.aliases}
255272
for f in files:
256-
for t in f.tables:
273+
for t in sorted(f.tables, key=lambda t: bool(t.aliases)):
274+
if t.aliases:
275+
t.name = next((a for a in t.aliases if a.lower() in defined), t.name)
257276
nid = table_node(t.name, t)
258277
if t.model:
259278
model_to_table[t.model.lower()] = nid
279+
if t.aliases: # Eloquent model file -> its table
280+
add_edge(f.path, nid, "db", f"{t.model} model → {t.name}")
260281

261282
# ---- call edges --------------------------------------------------
262283
for f in files:
@@ -288,12 +309,14 @@ def table_node(t_name: str, t: Optional[TableInfo] = None) -> str:
288309

289310
# ---- file-level connections ---------------------------------------
290311
file_set = {f.path for f in files}
312+
called_from_page: set = set() # own functions a page calls (onclick="f()")
291313
for f in files:
292314
fname = f.path.split("/")[-1]
293315
local = {fn.name: fn_nodes[(f.path, fn.name)] for fn in f.functions}
294316
# top-level calls: page/script code calling into functions
295317
for callee in f.calls:
296318
if callee in local:
319+
called_from_page.add(local[callee])
297320
continue # a file already implicitly contains its own fns
298321
candidates = name_index.get(callee, [])
299322
if not candidates or callee in COMMON_NAMES:
@@ -332,13 +355,13 @@ def table_node(t_name: str, t: Optional[TableInfo] = None) -> str:
332355
target = handler_id or f.path
333356
route_index.append((r.method, _norm_path(r.path), target,
334357
f"{r.method} {r.path}"))
335-
if handler_id:
336-
# mark handler node with its route
337-
for n in nodes:
338-
if n["id"] == handler_id:
339-
n.setdefault("routes", []).append(
340-
{"method": r.method, "path": r.path})
341-
break
358+
# mark the handler with its route; closure routes (Laravel
359+
# `Route::get('/', function () {...})`) land on the file itself
360+
for n in nodes:
361+
if n["id"] == target:
362+
n.setdefault("routes", []).append(
363+
{"method": r.method, "path": r.path})
364+
break
342365

343366
# ---- api edges (frontend call -> backend route) -------------------
344367
def match_route(method: str, url: str) -> Optional[Tuple[str, str]]:
@@ -374,6 +397,8 @@ def match_route(method: str, url: str) -> Optional[Tuple[str, str]]:
374397
tid = model_to_table.get(ref.table.lower()) \
375398
or table_ids.get(ref.table.lower())
376399
if tid is None:
400+
if ref.via == "orm-guess":
401+
continue # `Foo::where()` where Foo isn't a known model
377402
# unknown table: create it only for confident refs
378403
if ref.via == "sql" or ref.table.lower() in table_ids:
379404
tid = table_node(ref.table)
@@ -389,6 +414,12 @@ def match_route(method: str, url: str) -> Optional[Tuple[str, str]]:
389414

390415
# ---- orphans + circular dependencies --------------------------------
391416
cycles = _mark_orphans_and_cycles(nodes, edges)
417+
# Eloquent model methods (relations, accessors) are reached through magic
418+
# properties like $booking->user, which static analysis can't see
419+
model_files = {f.path for f in files for t in f.tables if t.model and t.aliases}
420+
for n in nodes:
421+
if n.get("orphan") and (n.get("file") in model_files or n["id"] in called_from_page):
422+
del n["orphan"]
392423

393424
# ---- stats ---------------------------------------------------------
394425
n_fns = sum(1 for n in nodes if n["kind"] in ("function", "method"))

‎codebread/context.py‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import os
11+
import re
1112
from collections import Counter, defaultdict
1213
from typing import Dict, Iterable
1314

@@ -24,6 +25,20 @@ def _short(items: Iterable[str], cap: int = LIST_CAP) -> str:
2425
return ", ".join(items[:cap]) + more
2526

2627

28+
def _human(name: str) -> str:
29+
return " ".join(re.sub(r"(?<=[a-z0-9])(?=[A-Z])|_+", " ", name).split()).lower()
30+
31+
32+
def _desc(fn: Dict) -> str:
33+
"""Drop auto-descriptions that only repeat the name ("Get Header Actions."),
34+
keep what they add after it ("handles POST /users; queries users")."""
35+
d = (fn.get("description") or "").strip()
36+
head, _, tail = d.partition(" — ")
37+
if head.rstrip(".").lower() == _human(fn["label"]):
38+
return tail.rstrip(".")
39+
return d
40+
41+
2742
def to_markdown(graph: Dict) -> str:
2843
nodes = {n["id"]: n for n in graph.get("nodes", [])}
2944
edges = [e for e in graph.get("edges", [])
@@ -76,11 +91,16 @@ def layer_rank(k: str) -> int:
7691
""]
7792

7893
# ---- entry points
79-
routes = sorted(((r["method"], r["path"], fn) for fl in fns.values() for fn in fl
80-
for r in fn.get("routes") or []), key=lambda x: (x[1], x[0]))
94+
routes = set()
95+
for n in nodes.values():
96+
for r in n.get("routes") or []:
97+
if n["kind"] == "file": # inline handler, e.g. a Laravel route closure
98+
routes.add((r["method"], r["path"], f"`{n['file']}` (inline)"))
99+
elif n["kind"] in ("function", "method"):
100+
routes.add((r["method"], r["path"], f"`{n['file']}:{n['label']}()`"))
81101
if routes:
82102
L += ["## HTTP routes", "", "| Method | Path | Handler |", "|---|---|---|"]
83-
L += [f"| {m} | `{p}` | `{fn['file']}:{fn['label']}()` |" for m, p, fn in routes]
103+
L += [f"| {m} | `{p}` | {h} |" for m, p, h in sorted(routes, key=lambda x: (x[1], x[0], x[2]))]
84104
L.append("")
85105

86106
# ---- data model
@@ -106,13 +126,17 @@ def layer_rank(k: str) -> int:
106126
by_layer[f.get("layer") or "unknown"].append(f)
107127
for layer in sorted(by_layer, key=layer_rank):
108128
L += [f"### {LAYER_NAME.get(layer, layer)}", ""]
129+
quiet = [] # files with nothing to say beyond their name
109130
for f in by_layer[layer]:
110131
path = f["file"]
111132
members = [f["id"]] + [fn["id"] for fn in fns.get(path, [])]
112133
talks = [ref(owner(e["target"])) for m in members for e in out_e[m]
113134
if owner(e["target"]) != f["id"]]
114135
heard = [ref(owner(e["source"])) for m in members for e in in_e[m]
115136
if owner(e["source"]) != f["id"]]
137+
if not (talks or heard or fns.get(path) or f.get("warnings")):
138+
quiet.append(f"`{path}`")
139+
continue
116140
L.append(f"#### `{path}` ({f.get('language', '?')}, {f.get('loc', 0)} lines)")
117141
if talks:
118142
L.append("Uses: " + _short(talks))
@@ -125,28 +149,30 @@ def layer_rank(k: str) -> int:
125149
params = ", ".join(fn.get("params") or [])
126150
cls = f"{fn['parentClass']}." if fn.get("parentClass") else ""
127151
line = f"- `{cls}{fn['label']}({params})` L{fn.get('line', '?')}"
128-
if fn.get("description"):
129-
line += f": {fn['description']}"
152+
if _desc(fn):
153+
line += f": {_desc(fn)}"
130154
calls = [ref(e["target"], path) for e in out_e[fn["id"]] if e["kind"] != "db"]
131155
tbls = [nodes[e["target"]]["label"] for e in out_e[fn["id"]] if e["kind"] == "db"]
132156
callers = [ref(e["source"], path) for e in in_e[fn["id"]]]
133157
extra = []
134158
if calls:
135159
extra.append("calls " + _short(calls))
136-
if tbls and not fn.get("description"): # descriptions already name their tables
160+
if tbls and not all(t in _desc(fn) for t in tbls): # skip if the description names them
137161
extra.append("tables " + _short(tbls))
138162
if callers:
139163
extra.append("called by " + _short(callers))
140164
if fn.get("cycle"):
141165
extra.append("in a call cycle")
142-
elif fn.get("orphan"):
143-
extra.append("no callers found")
166+
# no "no callers found": framework hooks (up(), boot(), form())
167+
# are called by the framework, so it would mislead more than help
144168
if extra:
145169
line += " | " + "; ".join(extra)
146170
L.append(line)
147171
if len(fl) > FNS_PER_FILE:
148172
L.append(f"- … {len(fl) - FNS_PER_FILE} more functions")
149173
L.append("")
174+
if quiet:
175+
L += ["Other files: " + _short(quiet, 40), ""]
150176

151177
# ---- things to be careful about
152178
notes = []

‎codebread/models.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ class TableInfo:
5858
fields: List[str] = field(default_factory=list)
5959
line: int = 0
6060
source: str = "" # file that defines it
61+
# candidate names when `name` is only a guess (e.g. Laravel's pluralised
62+
# Eloquent table names); resolved against real tables in build_graph
63+
aliases: List[str] = field(default_factory=list)
6164

6265

6366
@dataclass

‎codebread/parsers/generic_parser.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
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

2021
SQL_RE = re.compile(
@@ -23,6 +24,100 @@
2324
re.IGNORECASE)
2425
CALL_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+
26121
LANG_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

201308
PAGE_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

Comments
 (0)