sdivi_patterns/queries/http_routing.rs
1//! Callee-text classification for server-side HTTP route/endpoint declarations.
2//!
3//! Detects route-registration calls in Express/Koa/Fastify (TypeScript/JavaScript),
4//! Hono, Next.js route handlers, `http.HandleFunc`/Gin (Go), and `add_url_rule`
5//! (Python Flask/FastAPI imperative registration). Detection is anchored on the
6//! receiver token so that client-side HTTP calls (`axios.get`, `fetch`) stay in
7//! `data_access`.
8//!
9//! ## Receiver-allowlist precision
10//!
11//! The TS/JS regex matches only when the call receiver is a known server/router
12//! handle: `app`, `router`, `fastify`, `server`, `srv`. Go adds `http`, `mux`,
13//! `r`, `e`, `engine`, `g`, `rg`. A client call like `axios.get(url)` (receiver
14//! `axios`) is outside every allowlist and correctly stays in `data_access`.
15//!
16//! **Documented limitation:** An idiosyncratically-named server variable
17//! (`const api = express(); api.get(...)`) will not be detected — receiver-type
18//! inference would require a type-info pass outside the v0 node-kind model.
19//!
20//! ## NestJS / FastAPI distinction
21//!
22//! NestJS route decorators (`@Get('/')`, `@Post(...)`) and FastAPI route
23//! decorators (`@app.get(...)`, `@app.post(...)`) are `decorator` / `decorated_definition`
24//! nodes classified under `decorators` (M36.1/M36.2). They are intentionally **not**
25//! duplicated here — each route is counted once, under `decorators`.
26//!
27//! ## CALL_DISPATCH slot
28//!
29//! Registered at P7 in `CALL_DISPATCH` — above `logging` (P8) and `data_access` (P9)
30//! so that `app.get(...)` / `router.post(...)` are peeled off before the broad
31//! data-access `\b(get|post|...)\(` regex matches them.
32//!
33//! ## Python `add_url_rule`
34//!
35//! Flask/FastAPI also supports imperative `app.add_url_rule('/path', view_func=h)`.
36//! This is not covered by `decorators` because it is a call, not a decorator.
37//! Matched by a member-call regex anchored on `.add_url_rule(`.
38//!
39//! ## Seeds forward
40//!
41//! GraphQL resolvers, gRPC service methods, and tRPC routers are adjacent
42//! "endpoint declaration" idioms. Out of scope for M41; a future milestone could
43//! extend this category or introduce a sibling.
44
45use std::sync::LazyLock;
46
47use regex::Regex;
48
49/// Tree-sitter node kinds for HTTP routing patterns.
50///
51/// Empty — this category is detected entirely via callee-text inspection in
52/// [`matches_callee`]. `call_expression` nodes are already collected by the
53/// adapters; classification happens in `classify_hint`'s `CALL_DISPATCH` loop
54/// at slot P7.
55pub const NODE_KINDS: &[&str] = &[];
56
57// TypeScript / JavaScript — receiver-allowlist anchored.
58// Receiver: app | router | fastify | server | srv
59// Methods: get | post | put | delete | patch | head | options | all | use | route
60//
61// Disjointness from data_access:
62// axios.get(url) → receiver `axios` is NOT in the allowlist → falls through to P9
63// client.get(url) → receiver `client` is NOT in the allowlist → falls through to P9
64// app.get('/u', h) → receiver `app` IS in the allowlist → caught here at P7
65static TS_JS_RE: LazyLock<Regex> = LazyLock::new(|| {
66 Regex::new(
67 r"^(app|router|fastify|server|srv)\.(get|post|put|delete|patch|head|options|all|use|route)\(",
68 )
69 .expect("http_routing TS/JS regex is valid")
70});
71
72// Go — uppercase HTTP-verb method names on known router/engine receivers.
73// Receiver: http | mux | r | e | router | engine | g | rg
74// Methods: HandleFunc | Handle | GET | POST | PUT | DELETE | PATCH | Any | Group
75static GO_RE: LazyLock<Regex> = LazyLock::new(|| {
76 Regex::new(
77 r"^(http|mux|r|e|router|engine|g|rg)\.(HandleFunc|Handle|GET|POST|PUT|DELETE|PATCH|Any|Group)\(",
78 )
79 .expect("http_routing Go regex is valid")
80});
81
82// Python — Flask/FastAPI imperative URL registration.
83// `app.add_url_rule('/path', view_func=handler)` — member-call anchored on the
84// method name; any receiver matches (typically `app` or a Blueprint).
85// FastAPI/Flask decorator routes are `decorated_definition` → `decorators` (M36.2).
86static PYTHON_RE: LazyLock<Regex> =
87 LazyLock::new(|| Regex::new(r"\.add_url_rule\(").expect("http_routing Python regex is valid"));
88
89/// Return `true` when `text` looks like a server-side route/endpoint declaration.
90///
91/// Detection is receiver-allowlist anchored so that client HTTP calls
92/// (`axios.get`, `fetch`) stay in `data_access`. See module doc for the full
93/// allowlist and the NestJS / FastAPI decorator distinction.
94///
95/// # Examples
96///
97/// ```rust
98/// use sdivi_patterns::queries::http_routing::matches_callee;
99///
100/// // TypeScript/JavaScript — Express/Fastify/Koa
101/// assert!(matches_callee("app.get('/users', handler)", "typescript"));
102/// assert!(matches_callee("router.post('/user', cb)", "javascript"));
103/// assert!(matches_callee("fastify.route({ method: 'GET', ... })", "typescript"));
104/// assert!(matches_callee("server.use(middleware)", "typescript"));
105/// assert!(matches_callee("srv.all('*', h)", "javascript"));
106///
107/// // Go — net/http + Gin/Echo/Gorilla
108/// assert!(matches_callee("http.HandleFunc(\"/\", h)", "go"));
109/// assert!(matches_callee("r.GET(\"/users\", h)", "go"));
110/// assert!(matches_callee("mux.Handle(\"/\", h)", "go"));
111/// assert!(matches_callee("e.POST(\"/user\", h)", "go"));
112///
113/// // Python — Flask/FastAPI imperative
114/// assert!(matches_callee("app.add_url_rule('/users', view_func=h)", "python"));
115///
116/// // Client HTTP calls stay in data_access — NOT matched
117/// assert!(!matches_callee("axios.get(url)", "typescript"));
118/// assert!(!matches_callee("client.get(url)", "typescript"));
119/// assert!(!matches_callee("cache.get(key)", "typescript"));
120/// assert!(!matches_callee("db.query(sql)", "go"));
121/// assert!(!matches_callee("requests.get(url)", "python"));
122/// ```
123pub fn matches_callee(text: &str, language: &str) -> bool {
124 match language {
125 "typescript" | "javascript" => TS_JS_RE.is_match(text),
126 "go" => GO_RE.is_match(text),
127 "python" => PYTHON_RE.is_match(text),
128 _ => false,
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn app_get_matches_ts() {
138 assert!(matches_callee("app.get('/users', handler)", "typescript"));
139 }
140
141 #[test]
142 fn router_post_matches_js() {
143 assert!(matches_callee("router.post('/user', cb)", "javascript"));
144 }
145
146 #[test]
147 fn fastify_route_matches() {
148 assert!(matches_callee(
149 "fastify.route({ method: 'GET' })",
150 "typescript"
151 ));
152 }
153
154 #[test]
155 fn server_use_matches() {
156 assert!(matches_callee("server.use(middleware)", "typescript"));
157 }
158
159 #[test]
160 fn srv_all_matches() {
161 assert!(matches_callee("srv.all('*', h)", "javascript"));
162 }
163
164 #[test]
165 fn app_delete_matches() {
166 assert!(matches_callee("app.delete('/user/:id', h)", "typescript"));
167 }
168
169 #[test]
170 fn app_put_matches() {
171 assert!(matches_callee("app.put('/user', update)", "typescript"));
172 }
173
174 #[test]
175 fn app_patch_matches() {
176 assert!(matches_callee("app.patch('/user/:id', h)", "typescript"));
177 }
178
179 #[test]
180 fn app_head_matches() {
181 assert!(matches_callee("app.head('/ping', h)", "javascript"));
182 }
183
184 #[test]
185 fn app_options_matches() {
186 assert!(matches_callee("app.options('/api', h)", "typescript"));
187 }
188
189 #[test]
190 fn go_http_handle_func_matches() {
191 assert!(matches_callee("http.HandleFunc(\"/\", h)", "go"));
192 }
193
194 #[test]
195 fn go_gin_r_get_matches() {
196 assert!(matches_callee("r.GET(\"/users\", h)", "go"));
197 }
198
199 #[test]
200 fn go_echo_e_post_matches() {
201 assert!(matches_callee("e.POST(\"/user\", h)", "go"));
202 }
203
204 #[test]
205 fn go_mux_handle_matches() {
206 assert!(matches_callee("mux.Handle(\"/\", h)", "go"));
207 }
208
209 #[test]
210 fn go_engine_group_matches() {
211 assert!(matches_callee("engine.Group(\"/api\")", "go"));
212 }
213
214 #[test]
215 fn python_add_url_rule_matches() {
216 assert!(matches_callee(
217 "app.add_url_rule('/u', view_func=h)",
218 "python"
219 ));
220 }
221
222 #[test]
223 fn axios_get_does_not_match() {
224 // client GET stays data_access — receiver `axios` not in allowlist
225 assert!(!matches_callee("axios.get(url)", "typescript"));
226 }
227
228 #[test]
229 fn client_get_does_not_match() {
230 assert!(!matches_callee("client.get(url)", "typescript"));
231 }
232
233 #[test]
234 fn cache_get_does_not_match() {
235 assert!(!matches_callee("cache.get(key)", "typescript"));
236 }
237
238 #[test]
239 fn fetch_does_not_match() {
240 assert!(!matches_callee("fetch(\"/api\")", "typescript"));
241 }
242
243 #[test]
244 fn go_db_query_does_not_match() {
245 assert!(!matches_callee("db.Query(sql)", "go"));
246 }
247
248 #[test]
249 fn python_requests_get_does_not_match() {
250 assert!(!matches_callee("requests.get(url)", "python"));
251 }
252
253 #[test]
254 fn rust_returns_false() {
255 assert!(!matches_callee("app.get('/u', h)", "rust"));
256 }
257
258 #[test]
259 fn java_returns_false() {
260 assert!(!matches_callee("app.get('/u', h)", "java"));
261 }
262
263 #[test]
264 fn node_kinds_is_empty() {
265 // NODE_KINDS is intentionally empty: this category is callee-only (classified
266 // via classify_hint). The assertion guards that contract against regressions.
267 #[allow(clippy::const_is_empty)]
268 let empty = NODE_KINDS.is_empty();
269 assert!(empty);
270 }
271}