nyx/src/surface/lang/ts_next.rs
2026-05-21 14:35:42 -05:00

310 lines
10 KiB
Rust

//! TypeScript + Next.js framework probe.
//!
//! Recognises Next.js App Router route handlers (`app/**/route.{ts,tsx,js,jsx}`)
//! by walking exported function declarations whose name is one of the
//! HTTP method idents (`GET` / `POST` / …). Also recognises Pages
//! Router API routes (`pages/api/**/*.{ts,tsx,js,jsx}`) via the
//! `export default handler` pattern.
//!
//! Server actions (`'use server'` directive at file or function scope)
//! are also reported as entry points because they expose a function
//! callable from a React client over the wire.
use crate::entry_points::HttpMethod;
use crate::surface::lang::common::{loc_for, rel_file};
use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
use std::path::Path;
use tree_sitter::{Node, Tree};
pub fn detect_next_routes(
tree: &Tree,
bytes: &[u8],
path: &Path,
scan_root: Option<&Path>,
) -> Vec<SurfaceNode> {
let file_rel = rel_file(path, scan_root);
let mut out = Vec::new();
let app_router = is_app_router_route(path);
let pages_api = is_pages_api_route(path);
let route_path = derive_route_path(path);
let file_use_server = file_level_use_server(tree.root_node(), bytes);
if app_router {
collect_named_exports(tree.root_node(), bytes, &file_rel, &route_path, &mut out);
}
if pages_api {
collect_default_export(tree.root_node(), bytes, &file_rel, &route_path, &mut out);
}
if file_use_server {
collect_use_server_exports(tree.root_node(), bytes, &file_rel, &route_path, &mut out);
}
out
}
fn is_app_router_route(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if !matches!(name, "route.ts" | "route.tsx" | "route.js" | "route.jsx") {
return false;
}
path.components()
.any(|c| c.as_os_str().to_string_lossy() == "app")
}
fn is_pages_api_route(path: &Path) -> bool {
let comps = path.components().peekable();
let mut saw_pages = false;
for c in comps {
if c.as_os_str().to_string_lossy() == "pages" {
saw_pages = true;
} else if saw_pages && c.as_os_str().to_string_lossy() == "api" {
return true;
}
}
false
}
/// Convert `app/users/[id]/route.ts` → `/users/[id]`.
/// Convert `pages/api/users/index.ts` → `/users`.
fn derive_route_path(path: &Path) -> String {
let mut comps: Vec<String> = Vec::new();
let mut started = false;
for comp in path.components() {
let text = comp.as_os_str().to_string_lossy().into_owned();
if !started {
if text == "app" || text == "api" || text == "pages" {
started = true;
}
continue;
}
comps.push(text);
}
if let Some(last) = comps.last_mut() {
// Drop the basename; route file becomes the trailing segment.
if last.starts_with("route.") || last.starts_with("index.") {
comps.pop();
} else if let Some(idx) = last.rfind('.') {
last.truncate(idx);
}
}
let joined = comps.join("/");
if joined.is_empty() {
"/".to_string()
} else {
format!("/{}", joined)
}
}
fn collect_named_exports(
root: Node,
bytes: &[u8],
file_rel: &str,
route_path: &str,
out: &mut Vec<SurfaceNode>,
) {
fn recurse(
node: Node,
bytes: &[u8],
file_rel: &str,
route_path: &str,
out: &mut Vec<SurfaceNode>,
) {
if node.kind() == "export_statement" {
// Look for `export async function NAME(...)` or `export const NAME = ...`
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some((name, span)) = extract_named_function(child, bytes)
&& let Some(method) = HttpMethod::from_ident(&name)
{
out.push(SurfaceNode::EntryPoint(EntryPoint {
location: loc_for(node, file_rel),
framework: Framework::NextAppRouter,
method,
route: route_path.to_string(),
handler_name: name,
handler_location: SourceLocation::new(
file_rel,
(span.0 + 1) as u32,
(span.1 + 1) as u32,
),
auth_required: false,
}));
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
recurse(child, bytes, file_rel, route_path, out);
}
}
recurse(root, bytes, file_rel, route_path, out);
}
fn extract_named_function(node: Node, bytes: &[u8]) -> Option<(String, (usize, usize))> {
match node.kind() {
"function_declaration" => {
let name_node = node.child_by_field_name("name")?;
let name = name_node.utf8_text(bytes).ok()?.to_string();
let pos = node.start_position();
Some((name, (pos.row, pos.column)))
}
"lexical_declaration" | "variable_declaration" => {
let mut cursor = node.walk();
for decl in node.children(&mut cursor) {
if decl.kind() == "variable_declarator"
&& let Some(name_node) = decl.child_by_field_name("name")
&& let Ok(name) = name_node.utf8_text(bytes)
{
let pos = decl.start_position();
return Some((name.to_string(), (pos.row, pos.column)));
}
}
None
}
_ => None,
}
}
fn collect_default_export(
root: Node,
bytes: &[u8],
file_rel: &str,
route_path: &str,
out: &mut Vec<SurfaceNode>,
) {
fn recurse(
node: Node,
bytes: &[u8],
file_rel: &str,
route_path: &str,
out: &mut Vec<SurfaceNode>,
) {
if node.kind() == "export_statement" {
let raw = node.utf8_text(bytes).unwrap_or("");
if raw.contains("default") {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let name = match child.kind() {
"function_declaration" => child
.child_by_field_name("name")
.and_then(|n| n.utf8_text(bytes).ok())
.map(str::to_string),
"identifier" => child.utf8_text(bytes).ok().map(str::to_string),
"arrow_function" | "function" | "function_expression" => {
Some("default".to_string())
}
_ => None,
};
if let Some(name) = name {
out.push(SurfaceNode::EntryPoint(EntryPoint {
location: loc_for(node, file_rel),
framework: Framework::NextAppRouter,
method: HttpMethod::GET,
route: route_path.to_string(),
handler_name: name,
handler_location: loc_for(child, file_rel),
auth_required: false,
}));
return;
}
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
recurse(child, bytes, file_rel, route_path, out);
}
}
recurse(root, bytes, file_rel, route_path, out);
}
fn collect_use_server_exports(
root: Node,
bytes: &[u8],
file_rel: &str,
route_path: &str,
out: &mut Vec<SurfaceNode>,
) {
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() == "export_statement"
&& let Some((name, span)) = export_function_name(child, bytes)
{
out.push(SurfaceNode::EntryPoint(EntryPoint {
location: loc_for(child, file_rel),
framework: Framework::NextServerAction,
method: HttpMethod::POST,
route: route_path.to_string(),
handler_name: name,
handler_location: SourceLocation::new(
file_rel,
(span.0 + 1) as u32,
(span.1 + 1) as u32,
),
auth_required: false,
}));
}
}
}
fn export_function_name(node: Node, bytes: &[u8]) -> Option<(String, (usize, usize))> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(extracted) = extract_named_function(child, bytes) {
return Some(extracted);
}
}
None
}
fn file_level_use_server(root: Node, bytes: &[u8]) -> bool {
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() == "expression_statement" {
let mut cs = child.walk();
for c in child.children(&mut cs) {
if c.kind() == "string"
&& let Ok(text) = c.utf8_text(bytes)
{
let trimmed = text.trim().trim_matches(['\'', '"']);
if trimmed == "use server" {
return true;
}
}
}
return false;
}
if !matches!(child.kind(), "comment" | "import_statement") {
return false;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn parse(src: &str) -> (Tree, Vec<u8>) {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_typescript::LANGUAGE_TSX.into())
.unwrap();
(parser.parse(src, None).unwrap(), src.as_bytes().to_vec())
}
#[test]
fn detects_app_router_get() {
let src = "export async function GET(req: Request) { return new Response('ok'); }\n";
let (tree, bytes) = parse(src);
let nodes = detect_next_routes(&tree, &bytes, &PathBuf::from("app/users/route.ts"), None);
assert_eq!(nodes.len(), 1);
let SurfaceNode::EntryPoint(ep) = &nodes[0] else {
panic!()
};
assert_eq!(ep.method, HttpMethod::GET);
assert!(ep.route.contains("users"));
}
}