429 lines
13 KiB
Rust
429 lines
13 KiB
Rust
use proc_macro2::Span;
|
|
use syn::{
|
|
Ident, LitInt, LitStr, Token, braced, bracketed,
|
|
parse::{Parse, ParseStream},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AST types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub struct PiklTests {
|
|
pub modules: Vec<TestModule>,
|
|
}
|
|
|
|
pub struct TestModule {
|
|
pub kind: TestKind,
|
|
pub name: Ident,
|
|
pub fixtures: Fixtures,
|
|
pub tests: Vec<TestCase>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub enum TestKind {
|
|
Headless,
|
|
Filter,
|
|
Nav,
|
|
Menu,
|
|
}
|
|
|
|
pub struct Fixtures {
|
|
pub items: Option<Vec<String>>,
|
|
pub label_key: Option<String>,
|
|
pub viewport: Option<(usize, usize)>, // (height, count)
|
|
}
|
|
|
|
pub struct TestCase {
|
|
pub name: Ident,
|
|
pub actions: Vec<ActionExpr>,
|
|
pub stdout: Option<String>,
|
|
pub stderr_contains: Option<String>,
|
|
pub exit_code: Option<i32>,
|
|
pub query: Option<String>,
|
|
pub match_labels: Option<Vec<String>>,
|
|
pub cursor: Option<usize>,
|
|
pub offset: Option<usize>,
|
|
pub selected: Option<String>,
|
|
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<String>),
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Top-level parse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl Parse for PiklTests {
|
|
fn parse(input: ParseStream) -> syn::Result<Self> {
|
|
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<Self> {
|
|
let kind = parse_kind(input)?;
|
|
input.parse::<Token![mod]>()?;
|
|
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::<Token![:]>()?;
|
|
fixtures.items = Some(parse_string_list(&content)?);
|
|
eat_semi(&content);
|
|
}
|
|
"label_key" => {
|
|
consume_ident_or_keyword(&content)?;
|
|
content.parse::<Token![:]>()?;
|
|
let val: LitStr = content.parse()?;
|
|
fixtures.label_key = Some(val.value());
|
|
eat_semi(&content);
|
|
}
|
|
"viewport" => {
|
|
consume_ident_or_keyword(&content)?;
|
|
content.parse::<Token![:]>()?;
|
|
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<TestCase> {
|
|
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::<Token![:]>()?;
|
|
case.actions = parse_action_list(&content)?;
|
|
}
|
|
"stdout" => {
|
|
content.parse::<Token![:]>()?;
|
|
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::<Token![:]>()?;
|
|
let val: LitStr = content.parse()?;
|
|
case.stderr_contains = Some(val.value());
|
|
}
|
|
"exit" => {
|
|
content.parse::<Token![:]>()?;
|
|
let val: LitInt = content.parse()?;
|
|
case.exit_code = Some(val.base10_parse()?);
|
|
}
|
|
"query" => {
|
|
content.parse::<Token![:]>()?;
|
|
let val: LitStr = content.parse()?;
|
|
case.query = Some(val.value());
|
|
}
|
|
"matches" => {
|
|
content.parse::<Token![:]>()?;
|
|
case.match_labels = Some(parse_string_list(&content)?);
|
|
}
|
|
"cursor" => {
|
|
content.parse::<Token![:]>()?;
|
|
let val: LitInt = content.parse()?;
|
|
case.cursor = Some(val.base10_parse()?);
|
|
}
|
|
"offset" => {
|
|
content.parse::<Token![:]>()?;
|
|
let val: LitInt = content.parse()?;
|
|
case.offset = Some(val.base10_parse()?);
|
|
}
|
|
"selected" => {
|
|
content.parse::<Token![:]>()?;
|
|
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::<Token![:]>()?;
|
|
// 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<Vec<ActionExpr>> {
|
|
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::<Token![,]>()?;
|
|
}
|
|
}
|
|
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<ActionExpr> {
|
|
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<TestKind> {
|
|
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<String> {
|
|
let mut name = String::new();
|
|
|
|
// First segment: might be the `move` keyword or a regular ident.
|
|
if input.peek(Token![move]) {
|
|
input.parse::<Token![move]>()?;
|
|
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::<Token![-]>()?;
|
|
name.push('-');
|
|
if input.peek(Token![move]) {
|
|
input.parse::<Token![move]>()?;
|
|
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<Vec<String>> {
|
|
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::<Token![,]>()?;
|
|
}
|
|
}
|
|
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<usize> = None;
|
|
let mut count: Option<usize> = None;
|
|
|
|
while !content.is_empty() {
|
|
let key: Ident = content.parse()?;
|
|
content.parse::<Token![:]>()?;
|
|
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::<Token![,]>()?;
|
|
}
|
|
}
|
|
|
|
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<String> {
|
|
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::<Token![move]>()?;
|
|
} else if input.peek(Token![match]) {
|
|
input.parse::<Token![match]>()?;
|
|
} else {
|
|
input.parse::<Ident>()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Eat an optional semicolon.
|
|
fn eat_semi(input: ParseStream) {
|
|
if input.peek(Token![;]) {
|
|
let _ = input.parse::<Token![;]>();
|
|
}
|
|
}
|