sofie-model 0.4.0

The app model: modules, areas and routes, read from source without compiling it.
Documentation
use std::path::Path;

use syn::{Expr, Stmt};

use crate::syntax::{builder_function, line_of, single_argument, string_literal};
use crate::{MenuEntry, ModelError};

const SIGNATURE: &str = "`pub fn menu(menu: &mut Menu<Area>)`";

pub(crate) fn read_menu(file: &Path, syntax: &syn::File) -> Result<Vec<MenuEntry>, ModelError> {
    let (function, receiver) = builder_function(file, &syntax.items, "menu", SIGNATURE)?;
    function
        .block
        .stmts
        .iter()
        .map(|statement| {
            entry_of(statement, &receiver).ok_or_else(|| {
                ModelError::new(
                    file,
                    line_of(statement),
                    format!(
                        "`sofie gen` reads menus without compiling, so this body may only hold \
                         `{receiver}.entry(\"Label\", route)` statements, each optionally \
                         followed by `.feature(FLAG)`"
                    ),
                )
            })
        })
        .collect()
}

fn entry_of(statement: &Stmt, receiver: &str) -> Option<MenuEntry> {
    let Stmt::Expr(Expr::MethodCall(outermost), _) = statement else {
        return None;
    };
    let (entry, feature) = match outermost.receiver.as_ref() {
        Expr::MethodCall(entry) if outermost.method == "feature" => {
            (entry, Some(single_argument(outermost)?))
        }
        _ => (outermost, None),
    };
    let is_on_receiver = matches!(
        entry.receiver.as_ref(),
        Expr::Path(path) if path.path.is_ident(receiver)
    );
    if entry.method != "entry" || !is_on_receiver || entry.args.len() != 2 {
        return None;
    }
    Some(MenuEntry {
        label: string_literal(entry.args.first()?)?,
        feature,
        line: line_of(statement),
    })
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::read_menu;

    fn entries(source: &str) -> Result<Vec<(String, Option<String>, usize)>, String> {
        let syntax = syn::parse_file(source).unwrap();
        read_menu(Path::new("menu.rs"), &syntax)
            .map(|entries| {
                entries
                    .into_iter()
                    .map(|entry| (entry.label, entry.feature, entry.line))
                    .collect()
            })
            .map_err(|error| error.to_string())
    }

    #[test]
    fn reads_each_entry_s_label_and_flag() {
        let source = "pub fn menu(menu: &mut Menu<Console>) {\n\
                      menu.entry(\"Orders\", routes::console::list_orders());\n\
                      menu.entry(\"Refunds\", routes::console::list_refunds()).feature(crate::module::REFUNDS);\n}";

        assert_eq!(
            entries(source).unwrap(),
            [
                ("Orders".to_owned(), None, 2),
                (
                    "Refunds".to_owned(),
                    Some("crate::module::REFUNDS".to_owned()),
                    3
                ),
            ]
        );
    }

    #[test]
    fn refuses_other_statements() {
        let source = "pub fn menu(menu: &mut Menu<Console>) { let label = \"Orders\"; }";

        let error = entries(source).unwrap_err();

        assert!(error.contains("may only hold `menu.entry"), "{error}");
    }
}