nextcloud-route-extractor 0.1.3

Extract routes from nextcloud apps
Documentation
use crate::route::{AppRoutes, Route};
use php_literal_parser::from_str;
use streaming_iterator::StreamingIterator;
use tree_sitter::{Language, Parser, Query, QueryCursor};

pub fn extract_attribute_routes(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 attribute_query = Query::new(
        &language,
        r#"(attribute
            (name)@name
            parameters: (arguments)@arguments
        )@attr"#,
    )
    .unwrap();

    let mut argument_cursor = tree.walk();
    let mut attribute_cursor = QueryCursor::new();
    let mut attributes =
        attribute_cursor.matches(&attribute_query, tree.root_node(), php.as_bytes());

    let mut routes = AppRoutes::default();

    while let Some(attribute) = attributes.next() {
        let name = attribute
            .nodes_for_capture_index(0)
            .next()
            .unwrap()
            .utf8_text(php.as_bytes())
            .unwrap();
        let route_type = match name.split('/').next_back().unwrap() {
            "FrontpageRoute" => RouteType::Frontend,
            "ApiRoute" => RouteType::Ocs,
            _ => {
                continue;
            }
        };

        let method = attribute
            .nodes_for_capture_index(2)
            .next()
            .unwrap()
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .parent()
            .unwrap();
        let method_name = method
            .child_by_field_name("name")
            .unwrap()
            .utf8_text(php.as_bytes())
            .unwrap();
        let class = method.parent().unwrap().parent().unwrap();
        let class_name = class
            .child_by_field_name("name")
            .unwrap()
            .utf8_text(php.as_bytes())
            .unwrap();

        let mut route = Route {
            name: format!(
                "{}#{method_name}",
                class_name.strip_suffix("Controller").unwrap_or(class_name)
            ),
            ..Route::default()
        };

        let arguments = attribute.nodes_for_capture_index(1).next().unwrap();
        for (i, argument) in arguments
            .children(&mut argument_cursor)
            .filter(|node| node.kind() == "argument")
            .enumerate()
        {
            let name = argument
                .child_by_field_name("name")
                .map(|name| name.utf8_text(php.as_bytes()).unwrap());
            let value_str = argument
                .child(argument.child_count() - 1)
                .unwrap()
                .utf8_text(php.as_bytes())
                .unwrap();
            match (name, i) {
                (Some("verb"), _) | (None, 0) => {
                    route.verb = from_str(value_str).unwrap();
                }
                (Some("url"), _) | (None, 1) => {
                    route.url = from_str(value_str).unwrap();
                }
                (Some("requirements"), _) | (None, 2) => {
                    route.requirements = from_str(value_str).unwrap();
                }
                (Some("defaults"), _) | (None, 3) => {
                    route.defaults = from_str(value_str).unwrap();
                }
                (Some("root"), _) | (None, 4) => {
                    route.root = from_str(value_str).unwrap();
                }
                (Some("postfix"), _) | (None, 5) => {
                    route.postfix = from_str(value_str).unwrap();
                }
                _ => panic!("unexpected FrontpageRoute argument {name:?} at index {i}"),
            }
        }

        match route_type {
            RouteType::Frontend => routes.routes.push(route),
            RouteType::Ocs => routes.ocs.push(route),
        }
    }

    routes
}

enum RouteType {
    Frontend,
    Ocs,
}

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

    let expected = AppRoutes {
        routes: vec![
            Route {
                root: None,
                url: "login/webauthn/start".into(),
                name: "WebAuthn#startAuthentication".into(),
                verb: Verb::Post,
                requirements: hashmap! {},
                postfix: None,
                defaults: hashmap! {},
            },
            Route {
                root: None,
                url: "login/webauthn/finish".into(),
                name: "WebAuthn#finishAuthentication".into(),
                verb: Verb::Post,
                requirements: hashmap! {},
                postfix: None,
                defaults: hashmap! {},
            },
        ],
        ocs: vec![],
    };
    let code = include_str!("../tests/data/webauthncontroller.php");
    assert_eq!(expected, extract_attribute_routes(code));
}

#[test]
fn test_parse_requirements() {
    use maplit::hashmap;
    use php_literal_parser::Value;
    use std::collections::HashMap;

    assert_eq!(
        hashmap! {"version".into() => 1.into()},
        from_str::<HashMap<String, Value>>("['version' => 1]").unwrap()
    );
}