nextcloud-route-extractor 0.1.3

Extract routes from nextcloud apps
Documentation
use crate::route::AppRoutes;
use miette::Report;
use regex::Regex;
use std::borrow::Cow;
use std::fs::read_to_string;
use std::path::Path;
use streaming_iterator::StreamingIterator;
use tree_sitter::{Language, Parser, Query, QueryCursor, Tree};

pub fn extract_routes_php(path: &Path, php: &str) -> AppRoutes {
    let mut parser = Parser::new();
    let language: Language = tree_sitter_php::LANGUAGE_PHP.into();
    parser
        .set_language(&language)
        .expect("Error loading PHP parser");
    let tree = parser.parse(php, None).unwrap();

    let mut cursor = QueryCursor::new();
    let flattened_php = flatten_variables(&tree, &language, php);

    let tree = parser.parse(flattened_php.as_ref(), None).unwrap();
    let flattened_php = flatten_merged(&tree, &language, flattened_php.as_ref());

    let return_query = Query::new(&language, r#"(return_statement (_)@return)"#).unwrap();

    let tree = parser.parse(flattened_php.as_ref(), None).unwrap();
    let mut matches = cursor.matches(&return_query, tree.root_node(), flattened_php.as_bytes());
    let Some(return_statement) = matches.next() else {
        return AppRoutes::default();
    };
    let return_php = return_statement.captures[0]
        .node
        .utf8_text(flattened_php.as_bytes())
        .unwrap();

    if return_php.starts_with("array_merge_recursive") {
        return merge_includes(path.parent().unwrap(), return_php);
    }

    php_literal_parser::from_str(return_php).unwrap_or_else(|error| {
        let error = Report::from(error);
        eprintln!("Error while parsing return literal: {error:?}");
        AppRoutes::default()
    })
}

fn flatten_variables<'a>(tree: &Tree, language: &Language, code: &'a str) -> Cow<'a, str> {
    let assigment_query = Query::new(
        language,
        r#"(assignment_expression
            left: (variable_name)@name
            right: (_)@value
        )"#,
    )
    .unwrap();

    let mut cursor = QueryCursor::new();
    let mut matches = cursor.matches(&assigment_query, tree.root_node(), code.as_bytes());
    let mut assigments = Vec::new();

    while let Some(assigment) = matches.next() {
        assigments.push((
            assigment.captures[0]
                .node
                .utf8_text(code.as_bytes())
                .unwrap(),
            assigment.captures[1]
                .node
                .utf8_text(code.as_bytes())
                .unwrap(),
        ))
    }
    assigments.sort_by_key(|(name, _)| *name);
    assigments.reverse();

    let mut flattened_php = Cow::Borrowed(code);

    for (name, value) in assigments {
        flattened_php = flattened_php.replace(name, value).into();
    }

    flattened_php
}

fn flatten_merged<'a>(tree: &Tree, language: &Language, code: &'a str) -> Cow<'a, str> {
    let assigment_query = Query::new(
        language,
        r#"(function_call_expression
            function: (name)@name
            arguments: (_)@args
        )"#,
    )
    .unwrap();

    let mut cursor = QueryCursor::new();
    let mut tree_cursor = tree.walk();
    let mut matches = cursor.matches(&assigment_query, tree.root_node(), code.as_bytes());
    let mut merges = Vec::new();

    while let Some(merge) = matches.next() {
        let call = merge.captures[0]
            .node
            .parent()
            .unwrap()
            .utf8_text(code.as_bytes())
            .unwrap();
        let name = merge.captures[0].node.utf8_text(code.as_bytes()).unwrap();
        if name == "array_merge" {
            let args = merge.captures[1].node;
            let mut args_code = Vec::new();
            for arg in args.children(&mut tree_cursor) {
                if arg.kind() == "argument" {
                    args_code.push(arg.utf8_text(code.as_bytes()).unwrap());
                }
            }
            merges.push((call, args_code));
        }
    }

    let mut flattened_php = Cow::Borrowed(code);

    for (call, values) in merges {
        flattened_php = flattened_php.replace(call, values[0]).into();
    }

    flattened_php
}

fn merge_includes(base: &Path, code: &str) -> AppRoutes {
    let regex = Regex::new(r#"include\(__DIR__ . '([^']+)'\)"#).unwrap();

    let mut merged = AppRoutes::default();

    for included in regex.captures_iter(code) {
        let path = base.join(&included[1][1..]);
        let code = read_to_string(&path).unwrap();
        let routes = extract_routes_php(&path, &code);
        merged.routes.extend_from_slice(&routes.routes);
        merged.ocs.extend_from_slice(&routes.ocs);
    }

    merged
}

#[test]
fn test_parse_routes_php_with_variables() {
    use crate::route::{Route, Verb};
    use maplit::hashmap;

    let expected = AppRoutes {
        routes: vec![],
        ocs: vec![
            Route {
                root: None,
                url: "/api/v{version}/{fileId}".into(),
                name: "Api#get".into(),
                verb: Verb::Get,
                requirements: hashmap! {
                    "version".into() => "1".into(),
                },
                postfix: None,
                defaults: hashmap! {},
            },
            Route {
                root: None,
                url: "/api/v{version}/{fileId}".into(),
                name: "Api#set".into(),
                verb: Verb::Put,
                requirements: hashmap! {
                    "version".into() => "1".into(),
                },
                postfix: None,
                defaults: hashmap! {},
            },
            Route {
                root: None,
                url: "/api/v{version}/{fileId}".into(),
                name: "Api#remove".into(),
                verb: Verb::Delete,
                requirements: hashmap! {
                    "version".into() => "1".into(),
                },
                postfix: None,
                defaults: hashmap! {},
            },
        ],
    };
    let code = include_str!("../tests/data/file_reminders_routes.php");
    assert_eq!(expected, extract_routes_php(Path::new(""), code));
}