source-map-php 0.1.3

CLI-first PHP code search indexer for Laravel and Hyperf repositories
Documentation
use std::fs;
use std::path::Path;
use std::process::Command;

use anyhow::Result;
use regex::Regex;
use serde::Deserialize;

use crate::models::{RouteDoc, make_stable_id};
use crate::sanitizer::Sanitizer;

pub fn extract_routes(
    repo: &Path,
    repo_name: &str,
    sanitizer: &Sanitizer,
) -> Result<Vec<RouteDoc>> {
    if let Ok(routes) = artisan_routes(repo, repo_name) {
        return Ok(routes);
    }
    static_routes(repo, repo_name, sanitizer)
}

fn artisan_routes(repo: &Path, repo_name: &str) -> Result<Vec<RouteDoc>> {
    #[derive(Debug, Deserialize)]
    struct ArtisanRoute {
        method: String,
        uri: String,
        name: Option<String>,
        action: Option<String>,
        #[serde(default)]
        middleware: Vec<String>,
    }

    let output = Command::new("php")
        .arg("artisan")
        .arg("route:list")
        .arg("--json")
        .current_dir(repo)
        .output()?;
    if !output.status.success() {
        anyhow::bail!("artisan route:list failed");
    }
    let routes: Vec<ArtisanRoute> = serde_json::from_slice(&output.stdout)?;
    Ok(routes
        .into_iter()
        .map(|route| {
            let action = route.action.clone();
            let (controller, controller_method) = split_action(action.as_deref());
            RouteDoc {
                id: make_stable_id(&[repo_name, "laravel", &route.method, &route.uri]),
                repo: repo_name.to_string(),
                framework: "laravel".to_string(),
                method: route.method,
                uri: route.uri,
                route_name: route.name,
                action,
                controller,
                controller_method,
                middleware: route.middleware,
                related_symbols: Vec::new(),
                related_tests: Vec::new(),
                package_name: "root/app".to_string(),
                path: None,
                line_start: None,
            }
        })
        .collect())
}

fn static_routes(repo: &Path, repo_name: &str, sanitizer: &Sanitizer) -> Result<Vec<RouteDoc>> {
    let routes_dir = repo.join("routes");
    if !routes_dir.exists() {
        return Ok(Vec::new());
    }
    let route_re = Regex::new(
        r#"Route::(get|post|put|patch|delete|options|any)\(\s*['"]([^'"]+)['"]\s*,\s*\[?\s*([A-Za-z0-9_\\]+)::class\s*,\s*['"]([A-Za-z0-9_]+)['"]"#,
    )
    .unwrap();
    let name_re = Regex::new(r#"->name\(\s*['"]([^'"]+)['"]\s*\)"#).unwrap();
    let middleware_re = Regex::new(r#"->middleware\(([^)]+)\)"#).unwrap();

    let mut docs = Vec::new();
    for entry in walkdir::WalkDir::new(&routes_dir)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().is_file())
    {
        let contents = fs::read_to_string(entry.path())?;
        let path = entry
            .path()
            .strip_prefix(repo)
            .unwrap()
            .to_string_lossy()
            .into_owned();
        for (idx, line) in contents.lines().enumerate() {
            let Some(caps) = route_re.captures(line) else {
                continue;
            };
            let method = caps.get(1).unwrap().as_str().to_ascii_uppercase();
            let uri = sanitizer
                .sanitize_text(caps.get(2).unwrap().as_str())
                .unwrap_or_else(|| "/redacted".to_string());
            let controller = caps.get(3).unwrap().as_str().to_string();
            let controller_method = caps.get(4).unwrap().as_str().to_string();
            let route_name = name_re
                .captures(line)
                .and_then(|caps| caps.get(1))
                .map(|item| item.as_str().to_string());
            let middleware = middleware_re
                .captures(line)
                .and_then(|caps| caps.get(1))
                .map(|item| {
                    item.as_str()
                        .split(',')
                        .map(|part| {
                            part.trim_matches(|c| {
                                c == '\'' || c == '"' || c == ' ' || c == '[' || c == ']'
                            })
                        })
                        .filter(|part| !part.is_empty())
                        .map(ToOwned::to_owned)
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default();

            docs.push(RouteDoc {
                id: make_stable_id(&[
                    repo_name,
                    "laravel",
                    &method,
                    &uri,
                    &path,
                    &(idx + 1).to_string(),
                ]),
                repo: repo_name.to_string(),
                framework: "laravel".to_string(),
                method,
                uri,
                route_name,
                action: Some(format!("{controller}@{controller_method}")),
                controller: Some(controller.clone()),
                controller_method: Some(controller_method.clone()),
                middleware,
                related_symbols: vec![format!("{controller}::{controller_method}")],
                related_tests: Vec::new(),
                package_name: "root/app".to_string(),
                path: Some(path.clone()),
                line_start: Some(idx + 1),
            });
        }
    }
    Ok(docs)
}

fn split_action(action: Option<&str>) -> (Option<String>, Option<String>) {
    let Some(action) = action else {
        return (None, None);
    };
    if let Some((controller, method)) = action.split_once('@') {
        (Some(controller.to_string()), Some(method.to_string()))
    } else {
        (Some(action.to_string()), None)
    }
}