Skip to main content

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}