use proc_macro2::Span; use syn::{ Ident, LitInt, LitStr, Token, braced, bracketed, parse::{Parse, ParseStream}, }; // --------------------------------------------------------------------------- // AST types // --------------------------------------------------------------------------- pub struct PiklTests { pub modules: Vec, } pub struct TestModule { pub kind: TestKind, pub name: Ident, pub fixtures: Fixtures, pub tests: Vec, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum TestKind { Headless, Filter, Nav, Menu, } pub struct Fixtures { pub items: Option>, pub label_key: Option, pub viewport: Option<(usize, usize)>, // (height, count) } pub struct TestCase { pub name: Ident, pub actions: Vec, pub stdout: Option, pub stderr_contains: Option, pub exit_code: Option, pub query: Option, pub match_labels: Option>, pub cursor: Option, pub offset: Option, pub selected: Option, pub cancelled: bool, } pub enum ActionExpr { /// A simple action like "confirm", "cancel", "move-down". Simple(String), /// `filter "query text"` Filter(String), /// `raw "literal script line"` Raw(String), /// `add-items ["a", "b", "c"]` AddItems(Vec), } // --------------------------------------------------------------------------- // Top-level parse // --------------------------------------------------------------------------- impl Parse for PiklTests { fn parse(input: ParseStream) -> syn::Result { let mut modules = Vec::new(); while !input.is_empty() { modules.push(input.parse()?); } Ok(PiklTests { modules }) } } // --------------------------------------------------------------------------- // Module parse // --------------------------------------------------------------------------- impl Parse for TestModule { fn parse(input: ParseStream) -> syn::Result { let kind = parse_kind(input)?; input.parse::()?; let name: Ident = input.parse()?; let content; braced!(content in input); let mut fixtures = Fixtures { items: None, label_key: None, viewport: None, }; let mut tests = Vec::new(); while !content.is_empty() { // Peek at the next identifier to decide what we're parsing. let fork = content.fork(); let ident_str = parse_ident_or_keyword(&fork)?; match ident_str.as_str() { "test" => { tests.push(parse_test_case(&content)?); } "items" => { consume_ident_or_keyword(&content)?; content.parse::()?; fixtures.items = Some(parse_string_list(&content)?); eat_semi(&content); } "label_key" => { consume_ident_or_keyword(&content)?; content.parse::()?; let val: LitStr = content.parse()?; fixtures.label_key = Some(val.value()); eat_semi(&content); } "viewport" => { consume_ident_or_keyword(&content)?; content.parse::()?; fixtures.viewport = Some(parse_viewport_def(&content)?); eat_semi(&content); } _ => { return Err(syn::Error::new( content.span(), format!( "unexpected field '{ident_str}', expected test, items, label_key, or viewport" ), )); } } } Ok(TestModule { kind, name, fixtures, tests, }) } } // --------------------------------------------------------------------------- // Test case parse // --------------------------------------------------------------------------- /// Parse a single `test name { ... }` block inside a module. fn parse_test_case(input: ParseStream) -> syn::Result { consume_ident_or_keyword(input)?; // "test" let name: Ident = input.parse()?; let content; braced!(content in input); let mut case = TestCase { name, actions: Vec::new(), stdout: None, stderr_contains: None, exit_code: None, query: None, match_labels: None, cursor: None, offset: None, selected: None, cancelled: false, }; while !content.is_empty() { let field = parse_ident_or_keyword(&content)?; // Consume the ident we just peeked. consume_ident_or_keyword(&content)?; match field.as_str() { "actions" => { content.parse::()?; case.actions = parse_action_list(&content)?; } "stdout" => { content.parse::()?; let val: LitStr = content.parse()?; case.stdout = Some(val.value()); } "stderr" => { // stderr contains: "text" let kw = parse_ident_or_keyword(&content)?; consume_ident_or_keyword(&content)?; if kw != "contains" { return Err(syn::Error::new( content.span(), "expected 'contains' after 'stderr'", )); } content.parse::()?; let val: LitStr = content.parse()?; case.stderr_contains = Some(val.value()); } "exit" => { content.parse::()?; let val: LitInt = content.parse()?; case.exit_code = Some(val.base10_parse()?); } "query" => { content.parse::()?; let val: LitStr = content.parse()?; case.query = Some(val.value()); } "matches" => { content.parse::()?; case.match_labels = Some(parse_string_list(&content)?); } "cursor" => { content.parse::()?; let val: LitInt = content.parse()?; case.cursor = Some(val.base10_parse()?); } "offset" => { content.parse::()?; let val: LitInt = content.parse()?; case.offset = Some(val.base10_parse()?); } "selected" => { content.parse::()?; let val: LitStr = content.parse()?; case.selected = Some(val.value()); } "cancelled" => { // Just the keyword presence means true. Optionally parse `: true`. if content.peek(Token![:]) { content.parse::()?; // Accept `true` or just skip if content.peek(Ident) { consume_ident_or_keyword(&content)?; } } case.cancelled = true; } _ => { return Err(syn::Error::new( content.span(), format!("unknown test field: '{field}'"), )); } } } Ok(case) } // --------------------------------------------------------------------------- // Action parsing // --------------------------------------------------------------------------- /// Parse `[action1, action2, ...]` inside a test case's /// `actions:` field. fn parse_action_list(input: ParseStream) -> syn::Result> { let content; bracketed!(content in input); let mut actions = Vec::new(); while !content.is_empty() { actions.push(parse_action_expr(&content)?); if content.peek(Token![,]) { content.parse::()?; } } Ok(actions) } /// Parse a single action expression: `confirm`, `filter "text"`, /// `raw "line"`, or `add-items ["a", "b"]`. fn parse_action_expr(input: ParseStream) -> syn::Result { let name = parse_hyphenated_name(input)?; match name.as_str() { "filter" => { let val: LitStr = input.parse()?; Ok(ActionExpr::Filter(val.value())) } "raw" => { let val: LitStr = input.parse()?; Ok(ActionExpr::Raw(val.value())) } "add-items" => { let items = parse_string_list(input)?; Ok(ActionExpr::AddItems(items)) } _ => Ok(ActionExpr::Simple(name)), } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Parse the test module kind keyword: `headless`, `filter`, /// `nav`, or `menu`. fn parse_kind(input: ParseStream) -> syn::Result { let ident: Ident = input.parse()?; match ident.to_string().as_str() { "headless" => Ok(TestKind::Headless), "filter" => Ok(TestKind::Filter), "nav" => Ok(TestKind::Nav), "menu" => Ok(TestKind::Menu), other => Err(syn::Error::new( ident.span(), format!("unknown test kind '{other}', expected headless, filter, nav, or menu"), )), } } /// Parse a potentially hyphenated name like "move-down" or "move-to-top". /// Handles the `move` keyword specially since it's reserved in Rust. fn parse_hyphenated_name(input: ParseStream) -> syn::Result { let mut name = String::new(); // First segment: might be the `move` keyword or a regular ident. if input.peek(Token![move]) { input.parse::()?; name.push_str("move"); } else { let ident: Ident = input.parse()?; name.push_str(&ident.to_string()); } // Consume hyphenated continuations: `-ident` // Be careful not to consume `-` that's actually a negative number. while input.peek(Token![-]) && !input.peek2(LitInt) { input.parse::()?; name.push('-'); if input.peek(Token![move]) { input.parse::()?; name.push_str("move"); } else { let next: Ident = input.parse()?; name.push_str(&next.to_string()); } } Ok(name) } /// Parse `["a", "b", "c"]`. fn parse_string_list(input: ParseStream) -> syn::Result> { let content; bracketed!(content in input); let mut items = Vec::new(); while !content.is_empty() { let val: LitStr = content.parse()?; items.push(val.value()); if content.peek(Token![,]) { content.parse::()?; } } Ok(items) } /// Parse `{ height: N, count: N }`. fn parse_viewport_def(input: ParseStream) -> syn::Result<(usize, usize)> { let content; braced!(content in input); let mut height: Option = None; let mut count: Option = None; while !content.is_empty() { let key: Ident = content.parse()?; content.parse::()?; let val: LitInt = content.parse()?; let n: usize = val.base10_parse()?; match key.to_string().as_str() { "height" => height = Some(n), "count" => count = Some(n), other => { return Err(syn::Error::new( key.span(), format!("unknown viewport field '{other}', expected height or count"), )); } } if content.peek(Token![,]) { content.parse::()?; } } let h = height.ok_or_else(|| syn::Error::new(Span::call_site(), "viewport missing 'height'"))?; let c = count.ok_or_else(|| syn::Error::new(Span::call_site(), "viewport missing 'count'"))?; Ok((h, c)) } /// Peek at the next ident-like token without consuming it. /// Handles Rust keywords that might appear as DSL field names. fn parse_ident_or_keyword(input: ParseStream) -> syn::Result { if input.peek(Token![move]) { Ok("move".to_string()) } else if input.peek(Token![match]) { Ok("match".to_string()) } else if input.peek(Ident) { let fork = input.fork(); let ident: Ident = fork.parse()?; Ok(ident.to_string()) } else { Err(input.error("expected identifier")) } } /// Consume an ident-like token (including keywords used as DSL fields). fn consume_ident_or_keyword(input: ParseStream) -> syn::Result<()> { if input.peek(Token![move]) { input.parse::()?; } else if input.peek(Token![match]) { input.parse::()?; } else { input.parse::()?; } Ok(()) } /// Eat an optional semicolon. fn eat_semi(input: ParseStream) { if input.peek(Token![;]) { let _ = input.parse::(); } }