use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; use crate::parse::{ActionExpr, Fixtures, PiklTests, TestCase, TestKind, TestModule}; /// Generate all test modules from the parsed DSL. pub fn generate(input: &PiklTests) -> syn::Result { let mut output = TokenStream::new(); for module in &input.modules { output.extend(gen_module(module)?); } Ok(output) } /// Generate a single `mod name { ... }` block with test /// functions and the right imports for the test kind. fn gen_module(module: &TestModule) -> syn::Result { let mod_name = &module.name; let mut test_fns = Vec::new(); for case in &module.tests { let tokens = match module.kind { TestKind::Headless => gen_headless(case, &module.fixtures)?, TestKind::Filter => gen_filter(case, &module.fixtures)?, TestKind::Nav => gen_nav(case, &module.fixtures)?, TestKind::Menu => gen_menu(case, &module.fixtures)?, }; test_fns.push(tokens); } let imports = gen_imports(module.kind); Ok(quote! { mod #mod_name { #imports #(#test_fns)* } }) } /// Emit the `use` imports needed for a given test kind. fn gen_imports(kind: TestKind) -> TokenStream { match kind { TestKind::Headless => { quote! { use super::*; } } TestKind::Filter => { quote! { use pikl_core::item::Item; use pikl_core::filter::{Filter, FuzzyFilter}; } } TestKind::Nav => { quote! { use pikl_core::navigation::Viewport; } } TestKind::Menu => { quote! { use pikl_core::item::Item; use pikl_core::event::{Action, MenuEvent, MenuResult}; use pikl_core::menu::MenuRunner; use pikl_core::json_menu::JsonMenu; } } } } // --------------------------------------------------------------------------- // Headless // --------------------------------------------------------------------------- /// Generate a headless integration test that spawns the pikl /// binary, feeds it items and a script, and asserts on /// stdout/stderr/exit code. fn gen_headless(case: &TestCase, fixtures: &Fixtures) -> syn::Result { let test_name = &case.name; // Build items string: "item1\nitem2\n" let items_str = match &fixtures.items { Some(items) => { let mut s = String::new(); for item in items { s.push_str(item); s.push('\n'); } s } None => String::new(), }; // Build script string from actions let script = build_headless_script(&case.actions); // Build extra CLI args (e.g. --label-key, --multi) let mut extra_args: Vec = Vec::new(); if let Some(ref key) = fixtures.label_key { extra_args.push(quote! { "--label-key" }); extra_args.push(quote! { #key }); } if fixtures.multi == Some(true) { extra_args.push(quote! { "--multi" }); } // Build assertions let mut asserts = Vec::new(); if let Some(exit) = case.exit_code { asserts.push(quote! { assert_eq!( code, #exit, "expected exit {}, got {}, stderr: {}", #exit, code, stderr ); }); } if let Some(ref expected) = case.stdout { if expected.is_empty() { asserts.push(quote! { assert!( stdout.trim().is_empty(), "expected empty stdout, got: {}", stdout ); }); } else { asserts.push(quote! { assert!( stdout.contains(#expected), "expected stdout to contain {:?}, got: {}", #expected, stdout ); }); } } if let Some(ref expected) = case.stderr_contains { asserts.push(quote! { assert!( stderr.contains(#expected), "expected stderr to contain {:?}, got: {}", #expected, stderr ); }); } Ok(quote! { #[test] fn #test_name() { let (stdout, stderr, code) = run_pikl(#items_str, #script, &[#(#extra_args),*]); #(#asserts)* } }) } /// Turn action expressions into the newline-delimited script /// text that gets piped to `--action-fd`. fn build_headless_script(actions: &[ActionExpr]) -> String { let mut script = String::new(); for action in actions { match action { ActionExpr::Simple(name) => { script.push_str(name); script.push('\n'); } ActionExpr::Filter(query) => { script.push_str("filter "); script.push_str(query); script.push('\n'); } ActionExpr::Raw(line) => { script.push_str(line); script.push('\n'); } ActionExpr::AddItems(_) => { // Not applicable for headless. Items come from stdin. } } } script } // --------------------------------------------------------------------------- // Filter // --------------------------------------------------------------------------- /// Generate a filter unit test: create items, push them /// into a `FuzzyFilter`, set the query, and assert on /// matched labels. fn gen_filter(case: &TestCase, fixtures: &Fixtures) -> syn::Result { let test_name = &case.name; let item_exprs = gen_item_constructors(fixtures); let query = case.query.as_deref().unwrap_or(""); let mut asserts = Vec::new(); if let Some(ref expected) = case.match_labels { asserts.push(quote! { let labels: Vec<&str> = (0..f.matched_count()) .filter_map(|i| f.matched_index(i)) .map(|idx| items[idx].label()) .collect(); let expected: Vec<&str> = vec![#(#expected),*]; assert_eq!( labels, expected, "query {:?}: expected {:?}, got {:?}", #query, expected, labels ); }); } Ok(quote! { #[test] fn #test_name() { let items: Vec = vec![#(#item_exprs),*]; let mut f = FuzzyFilter::new(); for (i, item) in items.iter().enumerate() { f.push(i, item.label()); } f.set_query(#query); #(#asserts)* } }) } // --------------------------------------------------------------------------- // Nav // --------------------------------------------------------------------------- /// Generate a navigation unit test: create a viewport, run /// movement actions, and assert on cursor/offset. fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result { let test_name = &case.name; let (height, count) = fixtures.viewport.unwrap_or((10, 20)); let height_lit = height; let count_lit = count; let action_calls = gen_nav_actions(&case.actions)?; let mut asserts = Vec::new(); if let Some(cursor) = case.cursor { asserts.push(quote! { assert_eq!( v.cursor(), #cursor, "expected cursor {}, got {}", #cursor, v.cursor() ); }); } if let Some(offset) = case.offset { asserts.push(quote! { assert_eq!( v.scroll_offset(), #offset, "expected offset {}, got {}", #offset, v.scroll_offset() ); }); } Ok(quote! { #[test] fn #test_name() { let mut v = Viewport::new(); v.set_height(#height_lit); v.set_filtered_count(#count_lit); #(#action_calls)* #(#asserts)* } }) } /// Convert DSL action names to `Viewport` method calls /// (e.g. `move-down` becomes `v.move_down()`). fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result> { let mut calls = Vec::new(); for action in actions { match action { ActionExpr::Simple(name) => { let method = Ident::new(&name.replace('-', "_"), Span::call_site()); let needs_count = matches!( name.as_str(), "move-up" | "move-down" | "page-up" | "page-down" | "half-page-up" | "half-page-down" ); if needs_count { calls.push(quote! { v.#method(1); }); } else { calls.push(quote! { v.#method(); }); } } _ => { return Err(syn::Error::new( Span::call_site(), format!( "nav tests only support simple actions, got: {:?}", action_debug(action) ), )); } } } Ok(calls) } // --------------------------------------------------------------------------- // Menu // --------------------------------------------------------------------------- /// Generate an async menu state machine test: create a menu, /// send actions, and assert on the final result (selected /// item or cancellation). fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result { let test_name = &case.name; let item_exprs = gen_item_constructors(fixtures); let label_key = fixtures.label_key.as_deref().unwrap_or("label"); let action_sends = gen_menu_actions(&case.actions)?; let result_assert = if case.cancelled { quote! { assert!( matches!(result, Ok(MenuResult::Cancelled)), "expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r)) ); } } else if let Some(ref expected) = case.selected { quote! { match &result { Ok(MenuResult::Selected { items, .. }) => { assert!(!items.is_empty(), "expected Selected with items, got empty"); let value = &items[0].0; let got = value.as_str() .or_else(|| value.get(#label_key).and_then(|v| v.as_str())) .unwrap_or(""); assert_eq!( got, #expected, "expected selected {:?}, got value: {:?}", #expected, value ); } other => panic!("expected Selected, got: {:?}", other), } } } else if let Some(ref expected) = case.selected_items { quote! { match &result { Ok(MenuResult::Selected { items, .. }) => { let mut labels: Vec<&str> = items.iter() .filter_map(|(v, _)| v.as_str() .or_else(|| v.get(#label_key).and_then(|v| v.as_str()))) .collect(); labels.sort(); let mut expected = vec![#(#expected),*]; expected.sort(); assert_eq!( labels, expected, "expected selected_items {:?}, got {:?}", expected, labels ); } other => panic!("expected Selected, got: {:?}", other), } } } else { // No assertion on result. Probably an error, but let it compile. quote! {} }; // If test expects cancellation via sender drop (no cancel action, no confirm), // we need to drop tx after sending actions. let drop_sender = quote! { drop(tx); }; let multi_setup = if fixtures.multi == Some(true) { quote! { menu.set_multi(true); } } else { quote! {} }; Ok(quote! { #[tokio::test] async fn #test_name() { let items = vec![#(#item_exprs),*]; let (mut menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string())); #multi_setup let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); // Wait for initial state broadcast. let _ = rx.recv().await; // Give the viewport some height so confirms work. let _ = tx.send(Action::Resize { height: 50 }).await; let _ = rx.recv().await; // Send all actions. #(#action_sends)* // Drop sender so menu loop can exit. #drop_sender let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); #result_assert } }) } /// Convert DSL actions to `tx.send(Action::...)` calls for /// menu tests. fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result> { let mut sends = Vec::new(); for action in actions { let expr = match action { ActionExpr::Simple(name) => { let variant = menu_action_variant(name)?; quote! { let _ = tx.send(#variant).await; } } ActionExpr::Filter(query) => { quote! { let _ = tx.send(Action::UpdateFilter(#query.to_string())).await; } } ActionExpr::AddItems(items) => { let item_exprs: Vec = items .iter() .map(|s| quote! { serde_json::Value::String(#s.to_string()) }) .collect(); quote! { let _ = tx.send(Action::AddItems(vec![#(#item_exprs),*])).await; } } ActionExpr::Raw(_) => { return Err(syn::Error::new( Span::call_site(), "raw actions are only supported in headless tests", )); } }; sends.push(expr); } Ok(sends) } /// Map a DSL action name like `"move-down"` to the /// corresponding `Action::MoveDown` token stream. fn menu_action_variant(name: &str) -> syn::Result { let tokens = match name { "confirm" => quote! { Action::Confirm }, "cancel" => quote! { Action::Cancel }, "move-down" => quote! { Action::MoveDown(1) }, "move-up" => quote! { Action::MoveUp(1) }, "move-to-top" => quote! { Action::MoveToTop }, "move-to-bottom" => quote! { Action::MoveToBottom }, "page-up" => quote! { Action::PageUp(1) }, "page-down" => quote! { Action::PageDown(1) }, "half-page-up" => quote! { Action::HalfPageUp(1) }, "half-page-down" => quote! { Action::HalfPageDown(1) }, "set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) }, "set-mode-normal" => quote! { Action::SetMode(pikl_core::event::Mode::Normal) }, "set-mode-visual" => quote! { Action::SetMode(pikl_core::event::Mode::Visual) }, "toggle-select" => quote! { Action::ToggleSelect }, "select-all" => quote! { Action::SelectAll }, "clear-selections" => quote! { Action::ClearSelections }, "undo-selection" => quote! { Action::UndoSelection }, "redo-selection" => quote! { Action::RedoSelection }, _ => { return Err(syn::Error::new( Span::call_site(), format!("unknown menu action: '{name}'"), )); } }; Ok(tokens) } // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- /// Generate `Item::from_plain_text("...")` expressions for /// each item in the fixtures. fn gen_item_constructors(fixtures: &Fixtures) -> Vec { match &fixtures.items { Some(items) => items .iter() .map(|s| quote! { Item::from_plain_text(#s) }) .collect(), None => Vec::new(), } } /// Format an action expression for use in error messages. fn action_debug(action: &ActionExpr) -> String { match action { ActionExpr::Simple(name) => name.clone(), ActionExpr::Filter(q) => format!("filter \"{q}\""), ActionExpr::Raw(r) => format!("raw \"{r}\""), ActionExpr::AddItems(items) => format!("add-items {:?}", items), } }