Skip to main content

pecto_typescript/extractors/
controller.rs

1use super::common::*;
2use crate::context::ParsedFile;
3use pecto_core::model::*;
4
5/// Extract endpoints from TypeScript/JavaScript files (Express, NestJS, Next.js).
6pub fn extract(file: &ParsedFile) -> Option<Capability> {
7    let root = file.tree.root_node();
8    let source = file.source.as_bytes();
9    let full_text = &file.source;
10
11    let mut endpoints = Vec::new();
12
13    // Strategy 1: NestJS decorators (@Controller, @Get, etc.)
14    extract_nestjs_endpoints(&root, source, &mut endpoints);
15
16    // Strategy 2: Express router/app calls (router.get, app.post, etc.)
17    extract_express_endpoints(full_text, &mut endpoints);
18
19    // Strategy 3: Next.js App Router (export function GET/POST in route.ts)
20    if file.path.contains("route.") {
21        extract_nextjs_endpoints(&root, source, file, &mut endpoints);
22    }
23
24    if endpoints.is_empty() {
25        return None;
26    }
27
28    let file_stem = file
29        .path
30        .rsplit('/')
31        .next()
32        .unwrap_or(&file.path)
33        .split('.')
34        .next()
35        .unwrap_or("unknown");
36    let capability_name = to_kebab_case(
37        &file_stem
38            .replace(".controller", "")
39            .replace(".routes", "")
40            .replace(".router", "")
41            .replace("route", "api"),
42    );
43
44    let mut capability = Capability::new(capability_name, file.path.clone());
45    capability.endpoints = endpoints;
46    Some(capability)
47}
48
49// ==================== NestJS ====================
50
51fn extract_nestjs_endpoints(
52    _root: &tree_sitter::Node,
53    source: &[u8],
54    endpoints: &mut Vec<Endpoint>,
55) {
56    let full_text = std::str::from_utf8(source).unwrap_or("");
57    // Quick check: does file contain NestJS patterns?
58    if !full_text.contains("@Controller") && !full_text.contains("@Get") {
59        return;
60    }
61
62    // Text-based NestJS extraction (reliable across tree-sitter versions)
63    let base_path = full_text
64        .split("@Controller(")
65        .nth(1)
66        .and_then(|s| s.split(')').next())
67        .map(|s| clean_string_literal(s.trim()))
68        .unwrap_or_default();
69
70    // Find methods with @Get/@Post etc.
71    let methods = [
72        ("@Get(", HttpMethod::Get),
73        ("@Post(", HttpMethod::Post),
74        ("@Put(", HttpMethod::Put),
75        ("@Delete(", HttpMethod::Delete),
76        ("@Patch(", HttpMethod::Patch),
77    ];
78
79    for (marker, http_method) in &methods {
80        let mut remaining = full_text;
81        while let Some(pos) = remaining.find(marker) {
82            let after = &remaining[pos + marker.len()..];
83            let method_path = after
84                .split(')')
85                .next()
86                .map(|s| clean_string_literal(s.trim()))
87                .unwrap_or_default();
88
89            let full_path = if base_path.is_empty() && method_path.is_empty() {
90                "/".to_string()
91            } else if method_path.is_empty() {
92                format!("/{}", base_path)
93            } else if base_path.is_empty() {
94                format!("/{}", method_path.trim_start_matches('/'))
95            } else {
96                format!(
97                    "/{}/{}",
98                    base_path.trim_matches('/'),
99                    method_path.trim_start_matches('/')
100                )
101            };
102
103            let normalized = normalize_path_params(&full_path);
104
105            // Check for @UseGuards nearby
106            let security = if full_text.contains("@UseGuards") {
107                Some(SecurityConfig {
108                    authentication: Some("required".to_string()),
109                    roles: Vec::new(),
110                    rate_limit: None,
111                    cors: None,
112                })
113            } else {
114                None
115            };
116
117            endpoints.push(Endpoint {
118                method: *http_method,
119                path: normalized,
120                input: None,
121                validation: Vec::new(),
122                behaviors: vec![Behavior {
123                    name: "success".to_string(),
124                    condition: None,
125                    returns: ResponseSpec {
126                        status: default_status(http_method),
127                        body: None,
128                    },
129                    side_effects: Vec::new(),
130                }],
131                security,
132            });
133
134            remaining = &remaining[pos + marker.len()..];
135        }
136    }
137}
138
139// ==================== Express ====================
140
141fn extract_express_endpoints(source: &str, endpoints: &mut Vec<Endpoint>) {
142    for line in source.lines() {
143        let trimmed = line.trim();
144
145        // Match: router.get("/path", ...) or app.post("/path", ...)
146        let method = if trimmed.contains(".get(") || trimmed.contains(".GET(") {
147            Some(HttpMethod::Get)
148        } else if trimmed.contains(".post(") || trimmed.contains(".POST(") {
149            Some(HttpMethod::Post)
150        } else if trimmed.contains(".put(") || trimmed.contains(".PUT(") {
151            Some(HttpMethod::Put)
152        } else if trimmed.contains(".delete(") || trimmed.contains(".DELETE(") {
153            Some(HttpMethod::Delete)
154        } else if trimmed.contains(".patch(") || trimmed.contains(".PATCH(") {
155            Some(HttpMethod::Patch)
156        } else {
157            None
158        };
159
160        let Some(http_method) = method else {
161            continue;
162        };
163
164        // Must look like a route definition (has a string path)
165        if !trimmed.contains('"') && !trimmed.contains('\'') && !trimmed.contains('`') {
166            continue;
167        }
168
169        // Skip non-route calls (e.g., array.get, map.delete)
170        let has_route_prefix = trimmed.contains("router.")
171            || trimmed.contains("app.")
172            || trimmed.contains("route.")
173            || trimmed.contains("server.");
174        if !has_route_prefix {
175            continue;
176        }
177
178        // Extract path string
179        if let Some(path) = extract_string_arg(trimmed)
180            && path.starts_with('/')
181        {
182            let normalized = normalize_path_params(&path);
183            endpoints.push(Endpoint {
184                method: http_method,
185                path: normalized,
186                input: None,
187                validation: Vec::new(),
188                behaviors: vec![Behavior {
189                    name: "success".to_string(),
190                    condition: None,
191                    returns: ResponseSpec {
192                        status: default_status(&http_method),
193                        body: None,
194                    },
195                    side_effects: Vec::new(),
196                }],
197                security: None,
198            });
199        }
200    }
201}
202
203// ==================== Next.js ====================
204
205fn extract_nextjs_endpoints(
206    root: &tree_sitter::Node,
207    source: &[u8],
208    file: &ParsedFile,
209    endpoints: &mut Vec<Endpoint>,
210) {
211    // Derive path from file path: app/api/users/[id]/route.ts → /api/users/{id}
212    let path = derive_nextjs_path(&file.path);
213
214    // Find exported functions named GET, POST, PUT, DELETE, PATCH
215    find_exported_functions(root, source, &path, endpoints);
216}
217
218fn derive_nextjs_path(file_path: &str) -> String {
219    // Find "app/" or "src/app/" prefix
220    let start = file_path.find("app/").map(|i| i + 4).unwrap_or(0);
221    let path_part = &file_path[start..];
222
223    // Remove route.ts/route.js
224    let dir = path_part
225        .rsplit('/')
226        .skip(1)
227        .collect::<Vec<_>>()
228        .into_iter()
229        .rev()
230        .collect::<Vec<_>>()
231        .join("/");
232
233    // Convert [param] to {param}
234    let mut result = String::from("/");
235    result.push_str(&dir.replace('[', "{").replace(']', "}"));
236    result
237}
238
239fn find_exported_functions(
240    node: &tree_sitter::Node,
241    source: &[u8],
242    path: &str,
243    endpoints: &mut Vec<Endpoint>,
244) {
245    for i in 0..node.named_child_count() {
246        let child = node.named_child(i).unwrap();
247
248        if child.kind() == "export_statement" {
249            let text = node_text(&child, source);
250            let methods = [
251                ("GET", HttpMethod::Get),
252                ("POST", HttpMethod::Post),
253                ("PUT", HttpMethod::Put),
254                ("DELETE", HttpMethod::Delete),
255                ("PATCH", HttpMethod::Patch),
256            ];
257
258            for (name, method) in &methods {
259                if text.contains(&format!("function {}", name))
260                    || text.contains(&format!("const {} ", name))
261                    || text.contains(&format!("async function {}", name))
262                {
263                    endpoints.push(Endpoint {
264                        method: *method,
265                        path: path.to_string(),
266                        input: None,
267                        validation: Vec::new(),
268                        behaviors: vec![Behavior {
269                            name: "success".to_string(),
270                            condition: None,
271                            returns: ResponseSpec {
272                                status: default_status(method),
273                                body: None,
274                            },
275                            side_effects: Vec::new(),
276                        }],
277                        security: None,
278                    });
279                }
280            }
281        }
282    }
283}
284
285// ==================== Helpers ====================
286
287fn extract_string_arg(line: &str) -> Option<String> {
288    // Find first string argument: "...", '...', or `...`
289    for delim in ['"', '\'', '`'] {
290        if let Some(start) = line.find(delim)
291            && let Some(end) = line[start + 1..].find(delim)
292        {
293            return Some(line[start + 1..start + 1 + end].to_string());
294        }
295    }
296    None
297}
298
299/// Convert Express :param to {param} for consistent output.
300fn normalize_path_params(path: &str) -> String {
301    let mut result = String::new();
302    let mut chars = path.chars().peekable();
303    while let Some(c) = chars.next() {
304        if c == ':' {
305            result.push('{');
306            while let Some(&next) = chars.peek() {
307                if next.is_alphanumeric() || next == '_' {
308                    result.push(chars.next().unwrap());
309                } else {
310                    break;
311                }
312            }
313            result.push('}');
314        } else {
315            result.push(c);
316        }
317    }
318    result
319}
320
321fn default_status(method: &HttpMethod) -> u16 {
322    match method {
323        HttpMethod::Post => 201,
324        HttpMethod::Delete => 204,
325        _ => 200,
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::context::ParsedFile;
333
334    fn parse_file(source: &str, path: &str) -> ParsedFile {
335        ParsedFile::parse(source.to_string(), path.to_string()).unwrap()
336    }
337
338    #[test]
339    fn test_express_routes() {
340        let source = r#"
341const express = require('express');
342const router = express.Router();
343
344router.get('/users', (req, res) => {
345    res.json(users);
346});
347
348router.post('/users', (req, res) => {
349    res.status(201).json(user);
350});
351
352router.get('/users/:id', (req, res) => {
353    res.json(user);
354});
355
356router.delete('/users/:id', (req, res) => {
357    res.status(204).send();
358});
359
360module.exports = router;
361"#;
362
363        let file = parse_file(source, "routes/users.ts");
364        let capability = extract(&file).unwrap();
365
366        assert_eq!(capability.endpoints.len(), 4);
367        assert!(matches!(capability.endpoints[0].method, HttpMethod::Get));
368        assert_eq!(capability.endpoints[0].path, "/users");
369        assert_eq!(capability.endpoints[2].path, "/users/{id}");
370        assert!(matches!(capability.endpoints[3].method, HttpMethod::Delete));
371    }
372
373    #[test]
374    fn test_nestjs_controller() {
375        let source = r#"
376import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
377
378@Controller('users')
379export class UsersController {
380    @Get()
381    findAll() {
382        return this.usersService.findAll();
383    }
384
385    @Get(':id')
386    findOne(@Param('id') id: string) {
387        return this.usersService.findOne(id);
388    }
389
390    @Post()
391    @UseGuards(AuthGuard)
392    create(@Body() dto: CreateUserDto) {
393        return this.usersService.create(dto);
394    }
395}
396"#;
397
398        let file = parse_file(source, "users.controller.ts");
399        let capability = extract(&file);
400
401        assert!(
402            capability.is_some(),
403            "Should find NestJS controller capability"
404        );
405        let cap = capability.unwrap();
406        assert!(
407            cap.endpoints.len() >= 3,
408            "Should find 3 endpoints, found {}",
409            cap.endpoints.len()
410        );
411    }
412
413    #[test]
414    fn test_nextjs_route() {
415        let source = r#"
416import { NextResponse } from 'next/server';
417
418export async function GET(request: Request) {
419    return NextResponse.json({ users: [] });
420}
421
422export async function POST(request: Request) {
423    const body = await request.json();
424    return NextResponse.json(body, { status: 201 });
425}
426"#;
427
428        let file = parse_file(source, "app/api/users/route.ts");
429        let capability = extract(&file).unwrap();
430
431        assert_eq!(capability.endpoints.len(), 2);
432        assert!(matches!(capability.endpoints[0].method, HttpMethod::Get));
433        assert_eq!(capability.endpoints[0].path, "/api/users");
434        assert!(matches!(capability.endpoints[1].method, HttpMethod::Post));
435    }
436
437    #[test]
438    fn test_nextjs_dynamic_route() {
439        let source = r#"
440export async function GET(request: Request, { params }: { params: { id: string } }) {
441    return NextResponse.json({ id: params.id });
442}
443
444export async function DELETE(request: Request, { params }: { params: { id: string } }) {
445    return new Response(null, { status: 204 });
446}
447"#;
448
449        let file = parse_file(source, "src/app/api/users/[id]/route.ts");
450        let capability = extract(&file).unwrap();
451
452        assert_eq!(capability.endpoints.len(), 2);
453        assert_eq!(capability.endpoints[0].path, "/api/users/{id}");
454    }
455
456    #[test]
457    fn test_no_routes() {
458        let source = r#"
459export function helper() {
460    return 42;
461}
462
463class Utils {
464    static format(s: string) { return s; }
465}
466"#;
467        let file = parse_file(source, "utils.ts");
468        assert!(extract(&file).is_none());
469    }
470}