feat(parser): add action parameters, repeater decorator, and named participants
Add syntax enhancements for more expressive behavior trees and relationships.
Action parameters:
- Support typed/positional parameters: WaitDuration(2.0)
- Support named parameters: SetValue(field: value)
- Enable inline values without field names
Repeater decorator:
- Add * { node } syntax for repeating behavior nodes
- Maps to Decorator("repeat", node)
Named participant blocks:
- Replace self/other blocks with named participant syntax
- Support multi-party relationships
- Example: Alice { role: seeker } instead of self { role: seeker }
Schedule block syntax:
- Require braces for schedule blocks to support narrative fields
- Update tests to use new syntax: 04:00 -> 06:00: Activity { }
This commit is contained in:
@@ -134,12 +134,14 @@ fn test_duplicate_definition_error() {
|
|||||||
declarations: vec![
|
declarations: vec![
|
||||||
Declaration::Character(Character {
|
Declaration::Character(Character {
|
||||||
name: "Martha".to_string(),
|
name: "Martha".to_string(),
|
||||||
|
species: None,
|
||||||
fields: vec![],
|
fields: vec![],
|
||||||
template: None,
|
template: None,
|
||||||
span: Span::new(0, 10),
|
span: Span::new(0, 10),
|
||||||
}),
|
}),
|
||||||
Declaration::Character(Character {
|
Declaration::Character(Character {
|
||||||
name: "Martha".to_string(),
|
name: "Martha".to_string(),
|
||||||
|
species: None,
|
||||||
fields: vec![],
|
fields: vec![],
|
||||||
template: None,
|
template: None,
|
||||||
span: Span::new(20, 30),
|
span: Span::new(20, 30),
|
||||||
@@ -207,6 +209,7 @@ fn test_unknown_life_arc_state_error() {
|
|||||||
states: vec![
|
states: vec![
|
||||||
ArcState {
|
ArcState {
|
||||||
name: "child".to_string(),
|
name: "child".to_string(),
|
||||||
|
on_enter: None,
|
||||||
transitions: vec![Transition {
|
transitions: vec![Transition {
|
||||||
to: "adult".to_string(), // 'adult' exists
|
to: "adult".to_string(), // 'adult' exists
|
||||||
condition: Expr::BoolLit(true),
|
condition: Expr::BoolLit(true),
|
||||||
@@ -216,6 +219,7 @@ fn test_unknown_life_arc_state_error() {
|
|||||||
},
|
},
|
||||||
ArcState {
|
ArcState {
|
||||||
name: "adult".to_string(),
|
name: "adult".to_string(),
|
||||||
|
on_enter: None,
|
||||||
transitions: vec![Transition {
|
transitions: vec![Transition {
|
||||||
to: "senior".to_string(), // 'senior' doesn't exist!
|
to: "senior".to_string(), // 'senior' doesn't exist!
|
||||||
condition: Expr::BoolLit(true),
|
condition: Expr::BoolLit(true),
|
||||||
@@ -323,6 +327,7 @@ fn test_schedule_overlap_error() {
|
|||||||
minute: 30,
|
minute: 30,
|
||||||
second: 0,
|
second: 0,
|
||||||
},
|
},
|
||||||
|
fields: vec![],
|
||||||
span: Span::new(0, 50),
|
span: Span::new(0, 50),
|
||||||
},
|
},
|
||||||
ScheduleBlock {
|
ScheduleBlock {
|
||||||
@@ -337,6 +342,7 @@ fn test_schedule_overlap_error() {
|
|||||||
minute: 0,
|
minute: 0,
|
||||||
second: 0,
|
second: 0,
|
||||||
},
|
},
|
||||||
|
fields: vec![],
|
||||||
span: Span::new(50, 100),
|
span: Span::new(50, 100),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -413,6 +419,7 @@ fn test_relationship_bond_out_of_range() {
|
|||||||
fn test_duplicate_field_in_convert() {
|
fn test_duplicate_field_in_convert() {
|
||||||
let character = Character {
|
let character = Character {
|
||||||
name: "Martha".to_string(),
|
name: "Martha".to_string(),
|
||||||
|
species: None,
|
||||||
fields: vec![
|
fields: vec![
|
||||||
Field {
|
Field {
|
||||||
name: "age".to_string(),
|
name: "age".to_string(),
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ mod tests {
|
|||||||
|
|
||||||
ResolvedCharacter {
|
ResolvedCharacter {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
species: None,
|
||||||
fields,
|
fields,
|
||||||
prose_blocks: HashMap::new(),
|
prose_blocks: HashMap::new(),
|
||||||
span: Span::new(0, 10),
|
span: Span::new(0, 10),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub enum ResolvedDeclaration {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ResolvedCharacter {
|
pub struct ResolvedCharacter {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub species: Option<String>,
|
||||||
pub fields: Vec<Field>,
|
pub fields: Vec<Field>,
|
||||||
pub span: Span,
|
pub span: Span,
|
||||||
/// Qualified path (e.g., characters::Martha)
|
/// Qualified path (e.g., characters::Martha)
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ mod tests {
|
|||||||
states: vec![
|
states: vec![
|
||||||
ArcState {
|
ArcState {
|
||||||
name: "start".to_string(),
|
name: "start".to_string(),
|
||||||
|
on_enter: None,
|
||||||
transitions: vec![Transition {
|
transitions: vec![Transition {
|
||||||
to: "end".to_string(),
|
to: "end".to_string(),
|
||||||
condition: Expr::Identifier(vec!["ready".to_string()]),
|
condition: Expr::Identifier(vec!["ready".to_string()]),
|
||||||
@@ -428,6 +429,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
ArcState {
|
ArcState {
|
||||||
name: "end".to_string(),
|
name: "end".to_string(),
|
||||||
|
on_enter: None,
|
||||||
transitions: vec![],
|
transitions: vec![],
|
||||||
span: Span::new(50, 100),
|
span: Span::new(50, 100),
|
||||||
},
|
},
|
||||||
@@ -446,6 +448,7 @@ mod tests {
|
|||||||
name: "Test".to_string(),
|
name: "Test".to_string(),
|
||||||
states: vec![ArcState {
|
states: vec![ArcState {
|
||||||
name: "start".to_string(),
|
name: "start".to_string(),
|
||||||
|
on_enter: None,
|
||||||
transitions: vec![Transition {
|
transitions: vec![Transition {
|
||||||
to: "nonexistent".to_string(),
|
to: "nonexistent".to_string(),
|
||||||
condition: Expr::Identifier(vec!["ready".to_string()]),
|
condition: Expr::Identifier(vec!["ready".to_string()]),
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ proptest! {
|
|||||||
states: vec![
|
states: vec![
|
||||||
ArcState {
|
ArcState {
|
||||||
name: state1_name.clone(),
|
name: state1_name.clone(),
|
||||||
|
on_enter: None,
|
||||||
transitions: vec![Transition {
|
transitions: vec![Transition {
|
||||||
to: state2_name.clone(),
|
to: state2_name.clone(),
|
||||||
condition: Expr::BoolLit(true),
|
condition: Expr::BoolLit(true),
|
||||||
@@ -170,6 +171,7 @@ proptest! {
|
|||||||
},
|
},
|
||||||
ArcState {
|
ArcState {
|
||||||
name: state2_name,
|
name: state2_name,
|
||||||
|
on_enter: None,
|
||||||
transitions: vec![],
|
transitions: vec![],
|
||||||
span: Span::new(50, 100),
|
span: Span::new(50, 100),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub enum UseKind {
|
|||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Character {
|
pub struct Character {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub species: Option<String>, // `: Species` - what the character fundamentally is
|
||||||
pub fields: Vec<Field>,
|
pub fields: Vec<Field>,
|
||||||
pub template: Option<Vec<String>>, // `from Template1, Template2`
|
pub template: Option<Vec<String>>, // `from Template1, Template2`
|
||||||
pub span: Span,
|
pub span: Span,
|
||||||
@@ -142,6 +143,7 @@ pub struct LifeArc {
|
|||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct ArcState {
|
pub struct ArcState {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub on_enter: Option<Vec<Field>>,
|
||||||
pub transitions: Vec<Transition>,
|
pub transitions: Vec<Transition>,
|
||||||
pub span: Span,
|
pub span: Span,
|
||||||
}
|
}
|
||||||
@@ -166,6 +168,7 @@ pub struct ScheduleBlock {
|
|||||||
pub start: Time,
|
pub start: Time,
|
||||||
pub end: Time,
|
pub end: Time,
|
||||||
pub activity: String,
|
pub activity: String,
|
||||||
|
pub fields: Vec<Field>,
|
||||||
pub span: Span,
|
pub span: Span,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +228,7 @@ pub struct Location {
|
|||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Species {
|
pub struct Species {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub includes: Vec<String>,
|
||||||
pub fields: Vec<Field>,
|
pub fields: Vec<Field>,
|
||||||
pub span: Span,
|
pub span: Span,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ pub enum Token {
|
|||||||
State,
|
State,
|
||||||
#[token("on")]
|
#[token("on")]
|
||||||
On,
|
On,
|
||||||
|
#[token("enter")]
|
||||||
|
Enter,
|
||||||
#[token("as")]
|
#[token("as")]
|
||||||
As,
|
As,
|
||||||
#[token("self")]
|
#[token("self")]
|
||||||
@@ -180,6 +182,7 @@ pub struct Lexer<'a> {
|
|||||||
position: usize,
|
position: usize,
|
||||||
state: LexerState,
|
state: LexerState,
|
||||||
normal_lexer: Option<logos::Lexer<'a, Token>>,
|
normal_lexer: Option<logos::Lexer<'a, Token>>,
|
||||||
|
lexer_base_offset: usize, // Offset of the substring that normal_lexer is lexing
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Lexer<'a> {
|
impl<'a> Lexer<'a> {
|
||||||
@@ -189,6 +192,7 @@ impl<'a> Lexer<'a> {
|
|||||||
position: 0,
|
position: 0,
|
||||||
state: LexerState::Normal,
|
state: LexerState::Normal,
|
||||||
normal_lexer: Some(Token::lexer(source)),
|
normal_lexer: Some(Token::lexer(source)),
|
||||||
|
lexer_base_offset: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +261,7 @@ impl<'a> Lexer<'a> {
|
|||||||
let start = content_start.saturating_sub(tag.len() + 4); // Include opening ---tag
|
let start = content_start.saturating_sub(tag.len() + 4); // Include opening ---tag
|
||||||
self.position = content_end + 3; // Skip closing ---
|
self.position = content_end + 3; // Skip closing ---
|
||||||
self.state = LexerState::Normal;
|
self.state = LexerState::Normal;
|
||||||
|
self.lexer_base_offset = self.position; // Update base offset for new substring
|
||||||
self.normal_lexer = Some(Token::lexer(&self.source[self.position..]));
|
self.normal_lexer = Some(Token::lexer(&self.source[self.position..]));
|
||||||
|
|
||||||
let prose_block = super::ast::ProseBlock {
|
let prose_block = super::ast::ProseBlock {
|
||||||
@@ -295,19 +300,25 @@ impl<'a> Iterator for Lexer<'a> {
|
|||||||
match token {
|
match token {
|
||||||
| Ok(Token::ProseMarker) => {
|
| Ok(Token::ProseMarker) => {
|
||||||
// Switch to prose mode
|
// Switch to prose mode
|
||||||
let marker_pos = span.start;
|
// span is relative to the substring that logos is lexing; add base offset
|
||||||
self.position = marker_pos;
|
self.position = self.lexer_base_offset + span.start;
|
||||||
self.state = LexerState::ProseTag;
|
self.state = LexerState::ProseTag;
|
||||||
self.normal_lexer = None;
|
self.normal_lexer = None;
|
||||||
self.scan_prose_tag()
|
self.scan_prose_tag()
|
||||||
},
|
},
|
||||||
| Ok(tok) => {
|
| Ok(tok) => {
|
||||||
self.position = span.end;
|
// Adjust span to be relative to original source
|
||||||
Some((span.start, tok, span.end))
|
let absolute_start = self.lexer_base_offset + span.start;
|
||||||
|
let absolute_end = self.lexer_base_offset + span.end;
|
||||||
|
self.position = absolute_end;
|
||||||
|
Some((absolute_start, tok, absolute_end))
|
||||||
},
|
},
|
||||||
| Err(_) => {
|
| Err(_) => {
|
||||||
self.position = span.end;
|
// Adjust span to be relative to original source
|
||||||
Some((span.start, Token::Error, span.end))
|
let absolute_start = self.lexer_base_offset + span.start;
|
||||||
|
let absolute_end = self.lexer_base_offset + span.end;
|
||||||
|
self.position = absolute_end;
|
||||||
|
Some((absolute_start, Token::Error, absolute_end))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -393,6 +404,38 @@ The bakery had a no-nonsense policy.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_prose_blocks() {
|
||||||
|
let input = r#"
|
||||||
|
---description
|
||||||
|
First prose block content.
|
||||||
|
---
|
||||||
|
---details
|
||||||
|
Second prose block content.
|
||||||
|
---
|
||||||
|
"#;
|
||||||
|
let lexer = Lexer::new(input);
|
||||||
|
let tokens: Vec<Token> = lexer.map(|(_, tok, _)| tok).collect();
|
||||||
|
|
||||||
|
assert_eq!(tokens.len(), 2, "Should have exactly 2 prose block tokens");
|
||||||
|
|
||||||
|
match &tokens[0] {
|
||||||
|
| Token::ProseBlock(pb) => {
|
||||||
|
assert_eq!(pb.tag, "description");
|
||||||
|
assert!(pb.content.contains("First prose block"));
|
||||||
|
},
|
||||||
|
| _ => panic!("Expected first ProseBlock, got {:?}", tokens[0]),
|
||||||
|
}
|
||||||
|
|
||||||
|
match &tokens[1] {
|
||||||
|
| Token::ProseBlock(pb) => {
|
||||||
|
assert_eq!(pb.tag, "details");
|
||||||
|
assert!(pb.content.contains("Second prose block"));
|
||||||
|
},
|
||||||
|
| _ => panic!("Expected second ProseBlock, got {:?}", tokens[1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_time_duration_literals() {
|
fn test_time_duration_literals() {
|
||||||
let input = "08:30 14:45:00 2h30m 45m";
|
let input = "08:30 14:45:00 2h30m 45m";
|
||||||
|
|||||||
@@ -55,11 +55,20 @@ PathSegments: Vec<String> = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
DottedPath: Vec<String> = {
|
||||||
|
<Ident> => vec![<>],
|
||||||
|
<mut v:DottedPath> "." <i:Ident> => {
|
||||||
|
v.push(i);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ===== Character =====
|
// ===== Character =====
|
||||||
|
|
||||||
Character: Character = {
|
Character: Character = {
|
||||||
"character" <name:Ident> <template:TemplateClause?> "{" <fields:Field*> "}" => Character {
|
"character" <name:Ident> <species:(":" <Ident>)?> <template:TemplateClause?> "{" <fields:Field*> "}" => Character {
|
||||||
name,
|
name,
|
||||||
|
species,
|
||||||
fields,
|
fields,
|
||||||
template,
|
template,
|
||||||
span: Span::new(0, 0),
|
span: Span::new(0, 0),
|
||||||
@@ -93,10 +102,15 @@ Include: String = {
|
|||||||
// ===== Fields =====
|
// ===== Fields =====
|
||||||
|
|
||||||
Field: Field = {
|
Field: Field = {
|
||||||
<name:Ident> ":" <value:Value> => Field {
|
<path:DottedPath> ":" <value:Value> => Field {
|
||||||
name,
|
name: path.join("."),
|
||||||
value,
|
value,
|
||||||
span: Span::new(0, 0),
|
span: Span::new(0, 0),
|
||||||
|
},
|
||||||
|
<pb:ProseBlock> => Field {
|
||||||
|
name: pb.tag.clone(),
|
||||||
|
value: Value::ProseBlock(pb),
|
||||||
|
span: Span::new(0, 0),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,7 +202,7 @@ OverrideOp: OverrideOp = {
|
|||||||
// ===== Life Arc =====
|
// ===== Life Arc =====
|
||||||
|
|
||||||
LifeArc: LifeArc = {
|
LifeArc: LifeArc = {
|
||||||
"life_arc" <name:Ident> "{" <states:ArcState*> "}" => LifeArc {
|
"life_arc" <name:Ident> "{" <fields:Field*> <states:ArcState*> "}" => LifeArc {
|
||||||
name,
|
name,
|
||||||
states,
|
states,
|
||||||
span: Span::new(0, 0),
|
span: Span::new(0, 0),
|
||||||
@@ -196,13 +210,18 @@ LifeArc: LifeArc = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ArcState: ArcState = {
|
ArcState: ArcState = {
|
||||||
"state" <name:Ident> "{" <transitions:Transition*> "}" => ArcState {
|
"state" <name:Ident> "{" <on_enter:OnEnter?> <fields:Field*> <transitions:Transition*> "}" => ArcState {
|
||||||
name,
|
name,
|
||||||
|
on_enter,
|
||||||
transitions,
|
transitions,
|
||||||
span: Span::new(0, 0),
|
span: Span::new(0, 0),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
OnEnter: Vec<Field> = {
|
||||||
|
"on" "enter" "{" <fields:Field*> "}" => fields
|
||||||
|
};
|
||||||
|
|
||||||
Transition: Transition = {
|
Transition: Transition = {
|
||||||
"on" <cond:Expr> "->" <to:Ident> => Transition {
|
"on" <cond:Expr> "->" <to:Ident> => Transition {
|
||||||
to,
|
to,
|
||||||
@@ -214,7 +233,7 @@ Transition: Transition = {
|
|||||||
// ===== Schedule =====
|
// ===== Schedule =====
|
||||||
|
|
||||||
Schedule: Schedule = {
|
Schedule: Schedule = {
|
||||||
"schedule" <name:Ident> "{" <blocks:ScheduleBlock*> "}" => Schedule {
|
"schedule" <name:Ident> "{" <fields:Field*> <blocks:ScheduleBlock*> "}" => Schedule {
|
||||||
name,
|
name,
|
||||||
blocks,
|
blocks,
|
||||||
span: Span::new(0, 0),
|
span: Span::new(0, 0),
|
||||||
@@ -222,10 +241,11 @@ Schedule: Schedule = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ScheduleBlock: ScheduleBlock = {
|
ScheduleBlock: ScheduleBlock = {
|
||||||
<start:Time> "->" <end:Time> ":" <activity:Ident> => ScheduleBlock {
|
<start:Time> "->" <end:Time> ":" <activity:Ident> "{" <fields:Field*> "}" => ScheduleBlock {
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
activity,
|
activity,
|
||||||
|
fields,
|
||||||
span: Span::new(0, 0),
|
span: Span::new(0, 0),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -233,7 +253,7 @@ ScheduleBlock: ScheduleBlock = {
|
|||||||
// ===== Behavior Trees =====
|
// ===== Behavior Trees =====
|
||||||
|
|
||||||
Behavior: Behavior = {
|
Behavior: Behavior = {
|
||||||
"behavior" <name:Ident> "{" <root:BehaviorNode> "}" => Behavior {
|
"behavior" <name:Ident> "{" <fields:Field*> <root:BehaviorNode> "}" => Behavior {
|
||||||
name,
|
name,
|
||||||
root,
|
root,
|
||||||
span: Span::new(0, 0),
|
span: Span::new(0, 0),
|
||||||
@@ -243,6 +263,7 @@ Behavior: Behavior = {
|
|||||||
BehaviorNode: BehaviorNode = {
|
BehaviorNode: BehaviorNode = {
|
||||||
<SelectorNode>,
|
<SelectorNode>,
|
||||||
<SequenceNode>,
|
<SequenceNode>,
|
||||||
|
<RepeatNode>,
|
||||||
<ActionNode>,
|
<ActionNode>,
|
||||||
<SubTreeNode>,
|
<SubTreeNode>,
|
||||||
};
|
};
|
||||||
@@ -255,11 +276,30 @@ SequenceNode: BehaviorNode = {
|
|||||||
">" "{" <nodes:BehaviorNode+> "}" => BehaviorNode::Sequence(nodes),
|
">" "{" <nodes:BehaviorNode+> "}" => BehaviorNode::Sequence(nodes),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
RepeatNode: BehaviorNode = {
|
||||||
|
"*" "{" <node:BehaviorNode> "}" => BehaviorNode::Decorator("repeat".to_string(), Box::new(node)),
|
||||||
|
};
|
||||||
|
|
||||||
ActionNode: BehaviorNode = {
|
ActionNode: BehaviorNode = {
|
||||||
<name:Ident> "(" <params:Comma<Field>> ")" => BehaviorNode::Action(name, params),
|
<name:Ident> "(" <params:Comma<ActionParam>> ")" => BehaviorNode::Action(name, params),
|
||||||
<name:Ident> => BehaviorNode::Action(name, vec![]),
|
<name:Ident> => BehaviorNode::Action(name, vec![]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ActionParam: Field = {
|
||||||
|
// Named parameter: field: value
|
||||||
|
<path:DottedPath> ":" <value:Value> => Field {
|
||||||
|
name: path.join("."),
|
||||||
|
value,
|
||||||
|
span: Span::new(0, 0),
|
||||||
|
},
|
||||||
|
// Positional parameter: just a value (use empty string as field name)
|
||||||
|
<value:Value> => Field {
|
||||||
|
name: String::new(),
|
||||||
|
value,
|
||||||
|
span: Span::new(0, 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
SubTreeNode: BehaviorNode = {
|
SubTreeNode: BehaviorNode = {
|
||||||
"@" <path:Path> => BehaviorNode::SubTree(path),
|
"@" <path:Path> => BehaviorNode::SubTree(path),
|
||||||
};
|
};
|
||||||
@@ -286,13 +326,30 @@ Relationship: Relationship = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Participant: Participant = {
|
Participant: Participant = {
|
||||||
<name:Path> <role:("as" <Ident>)?> <self_block:SelfBlock?> <other_block:OtherBlock?> => Participant {
|
// Participant with inline block after name
|
||||||
role,
|
<name:Path> "{" <fields:Field*> "}" => Participant {
|
||||||
|
role: None,
|
||||||
name,
|
name,
|
||||||
self_block,
|
self_block: Some(fields),
|
||||||
other_block,
|
other_block: None,
|
||||||
span: Span::new(0, 0),
|
span: Span::new(0, 0),
|
||||||
}
|
},
|
||||||
|
// Participant with role and inline block
|
||||||
|
<name:Path> "as" <role:Ident> "{" <fields:Field*> "}" => Participant {
|
||||||
|
role: Some(role),
|
||||||
|
name,
|
||||||
|
self_block: Some(fields),
|
||||||
|
other_block: None,
|
||||||
|
span: Span::new(0, 0),
|
||||||
|
},
|
||||||
|
// Participant without blocks (bare name)
|
||||||
|
<name:Path> => Participant {
|
||||||
|
role: None,
|
||||||
|
name,
|
||||||
|
self_block: None,
|
||||||
|
other_block: None,
|
||||||
|
span: Span::new(0, 0),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
SelfBlock: Vec<Field> = {
|
SelfBlock: Vec<Field> = {
|
||||||
@@ -316,8 +373,9 @@ Location: Location = {
|
|||||||
// ===== Species =====
|
// ===== Species =====
|
||||||
|
|
||||||
Species: Species = {
|
Species: Species = {
|
||||||
"species" <name:Ident> "{" <fields:Field*> "}" => Species {
|
"species" <name:Ident> "{" <includes:Include*> <fields:Field*> "}" => Species {
|
||||||
name,
|
name,
|
||||||
|
includes,
|
||||||
fields,
|
fields,
|
||||||
span: Span::new(0, 0),
|
span: Span::new(0, 0),
|
||||||
}
|
}
|
||||||
@@ -465,6 +523,7 @@ extern {
|
|||||||
"enum" => Token::Enum,
|
"enum" => Token::Enum,
|
||||||
"state" => Token::State,
|
"state" => Token::State,
|
||||||
"on" => Token::On,
|
"on" => Token::On,
|
||||||
|
"enter" => Token::Enter,
|
||||||
"as" => Token::As,
|
"as" => Token::As,
|
||||||
"self" => Token::SelfKw,
|
"self" => Token::SelfKw,
|
||||||
"other" => Token::Other,
|
"other" => Token::Other,
|
||||||
|
|||||||
8918
src/syntax/parser.rs
8918
src/syntax/parser.rs
File diff suppressed because it is too large
Load Diff
@@ -248,7 +248,7 @@ fn valid_schedule() -> impl Strategy<Value = String> {
|
|||||||
let (h1, m1, s1) = w[0];
|
let (h1, m1, s1) = w[0];
|
||||||
let (h2, m2, _) = w[1];
|
let (h2, m2, _) = w[1];
|
||||||
format!(
|
format!(
|
||||||
" {:02}:{:02}:{:02} -> {:02}:{:02}:00: activity",
|
" {:02}:{:02}:{:02} -> {:02}:{:02}:00: activity {{ }}",
|
||||||
h1, m1, s1, h2, m2
|
h1, m1, s1, h2, m2
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub enum ResolvedDeclaration {
|
|||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct ResolvedCharacter {
|
pub struct ResolvedCharacter {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub species: Option<String>,
|
||||||
pub fields: HashMap<String, Value>,
|
pub fields: HashMap<String, Value>,
|
||||||
pub prose_blocks: HashMap<String, ProseBlock>,
|
pub prose_blocks: HashMap<String, ProseBlock>,
|
||||||
pub span: Span,
|
pub span: Span,
|
||||||
|
|||||||
@@ -544,3 +544,485 @@ character Martha {
|
|||||||
// Parser catches this as UnrecognizedToken before validation
|
// Parser catches this as UnrecognizedToken before validation
|
||||||
assert!(stderr.contains("Parse error") || stderr.contains("UnrecognizedToken"));
|
assert!(stderr.contains("Parse error") || stderr.contains("UnrecognizedToken"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Prose Block Tests =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prose_block_in_institution() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("institution.sb"),
|
||||||
|
r#"
|
||||||
|
institution Bakery {
|
||||||
|
type: "business"
|
||||||
|
|
||||||
|
---description
|
||||||
|
A cozy neighborhood bakery that has been serving
|
||||||
|
the community for over 30 years. Known for its
|
||||||
|
fresh bread and friendly atmosphere.
|
||||||
|
---
|
||||||
|
|
||||||
|
established: 1990
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Institution with prose block should validate successfully. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
assert!(stdout.contains("Institutions: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_prose_blocks_in_same_declaration() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("multi_prose.sb"),
|
||||||
|
r#"
|
||||||
|
character Martha {
|
||||||
|
age: 34
|
||||||
|
|
||||||
|
---backstory
|
||||||
|
Martha grew up in a small town where she learned
|
||||||
|
the art of baking from her grandmother. Her passion
|
||||||
|
for creating delicious pastries has been lifelong.
|
||||||
|
---
|
||||||
|
|
||||||
|
trust: 0.8
|
||||||
|
|
||||||
|
---personality
|
||||||
|
Martha is kind-hearted and patient, always willing
|
||||||
|
to help her neighbors. She has a warm smile that
|
||||||
|
makes everyone feel welcome in her bakery.
|
||||||
|
---
|
||||||
|
|
||||||
|
---goals
|
||||||
|
She dreams of expanding her bakery and teaching
|
||||||
|
baking classes to the next generation of bakers
|
||||||
|
in the community.
|
||||||
|
---
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Character with multiple prose blocks should validate successfully. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
assert!(stdout.contains("Characters: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prose_blocks_in_behavior_tree() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("behavior.sb"),
|
||||||
|
r#"
|
||||||
|
behavior WakeUpRoutine {
|
||||||
|
---description
|
||||||
|
A simple morning routine that characters follow
|
||||||
|
when they wake up each day.
|
||||||
|
---
|
||||||
|
|
||||||
|
> {
|
||||||
|
WakeUp
|
||||||
|
GetDressed
|
||||||
|
EatBreakfast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Behavior tree with prose block should validate successfully. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
assert!(stdout.contains("Behaviors: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prose_blocks_in_life_arc() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("life_arc.sb"),
|
||||||
|
r#"
|
||||||
|
life_arc BakerCareer {
|
||||||
|
---description
|
||||||
|
The journey of becoming a master baker, from
|
||||||
|
apprentice to owning one's own bakery.
|
||||||
|
---
|
||||||
|
|
||||||
|
state Apprentice {
|
||||||
|
---details
|
||||||
|
Learning the basics of bread-making and pastry
|
||||||
|
under the guidance of an experienced baker.
|
||||||
|
---
|
||||||
|
|
||||||
|
on experience > 5 -> Journeyman
|
||||||
|
}
|
||||||
|
|
||||||
|
state Journeyman {
|
||||||
|
---details
|
||||||
|
Working independently and developing signature
|
||||||
|
recipes while saving to open a bakery.
|
||||||
|
---
|
||||||
|
|
||||||
|
on savings > 50000 -> MasterBaker
|
||||||
|
}
|
||||||
|
|
||||||
|
state MasterBaker {
|
||||||
|
---details
|
||||||
|
Running a successful bakery and mentoring the
|
||||||
|
next generation of bakers.
|
||||||
|
---
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Life arc with prose blocks should validate successfully. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
assert!(stdout.contains("Life Arcs: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prose_blocks_in_schedule() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("schedule.sb"),
|
||||||
|
r#"
|
||||||
|
schedule DailyRoutine {
|
||||||
|
---description
|
||||||
|
A typical weekday schedule for bakery workers
|
||||||
|
who start their day before dawn.
|
||||||
|
---
|
||||||
|
|
||||||
|
04:00 -> 06:00: PrepareDough { }
|
||||||
|
06:00 -> 08:00: BakeBread { }
|
||||||
|
08:00 -> 12:00: ServCustomers { }
|
||||||
|
12:00 -> 13:00: LunchBreak { }
|
||||||
|
13:00 -> 17:00: ServCustomers { }
|
||||||
|
17:00 -> 18:00: Cleanup { }
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Schedule with prose block should validate successfully. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
assert!(stdout.contains("Schedules: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prose_blocks_with_special_characters() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("special.sb"),
|
||||||
|
r#"
|
||||||
|
character Alice {
|
||||||
|
age: 7
|
||||||
|
|
||||||
|
---backstory
|
||||||
|
Alice was a curious young girl who often wondered about
|
||||||
|
the world around her. One day, she saw a White Rabbit
|
||||||
|
muttering "Oh dear! Oh dear! I shall be too late!" and
|
||||||
|
followed it down a rabbit-hole.
|
||||||
|
|
||||||
|
She fell down, down, down--would the fall never come to
|
||||||
|
an end? "I wonder how many miles I've fallen by this time?"
|
||||||
|
she said aloud.
|
||||||
|
---
|
||||||
|
|
||||||
|
curiosity: 0.95
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Prose block with quotes, dashes, and punctuation should validate. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prose_blocks_in_relationship() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("relationship.sb"),
|
||||||
|
r#"
|
||||||
|
character Martha {
|
||||||
|
age: 34
|
||||||
|
}
|
||||||
|
|
||||||
|
character David {
|
||||||
|
age: 42
|
||||||
|
}
|
||||||
|
|
||||||
|
relationship Spousal {
|
||||||
|
Martha
|
||||||
|
David
|
||||||
|
|
||||||
|
---bond_description
|
||||||
|
Martha and David have been married for 8 years.
|
||||||
|
They share a deep connection built on mutual
|
||||||
|
respect, trust, and shared dreams for the future.
|
||||||
|
---
|
||||||
|
|
||||||
|
bond: 0.9
|
||||||
|
coordination: cohabiting
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Relationship with prose block should validate successfully. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
assert!(stdout.contains("Characters: 2"));
|
||||||
|
assert!(stdout.contains("Relationships: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Species Tests =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_species_with_identity_operator() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("species.sb"),
|
||||||
|
r#"
|
||||||
|
species Human {
|
||||||
|
lifespan: 70..90
|
||||||
|
intelligence: "high"
|
||||||
|
}
|
||||||
|
|
||||||
|
character Alice: Human {
|
||||||
|
age: 7
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Character with species should validate successfully. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
assert!(stdout.contains("Characters: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_species_composition_with_includes() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("hybrid_species.sb"),
|
||||||
|
r#"
|
||||||
|
species Horse {
|
||||||
|
hoofed: true
|
||||||
|
mane_style: "long"
|
||||||
|
}
|
||||||
|
|
||||||
|
species Donkey {
|
||||||
|
hoofed: true
|
||||||
|
ear_size: "large"
|
||||||
|
}
|
||||||
|
|
||||||
|
species Mule {
|
||||||
|
include Horse
|
||||||
|
include Donkey
|
||||||
|
sterile: true
|
||||||
|
}
|
||||||
|
|
||||||
|
character MyMule: Mule {
|
||||||
|
age: 3
|
||||||
|
name: "Betsy"
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Hybrid species with composition should validate successfully. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
assert!(stdout.contains("Characters: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_character_without_species() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("no_species.sb"),
|
||||||
|
r#"
|
||||||
|
character TheNarrator {
|
||||||
|
abstract_entity: true
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Character without species should validate (species is optional). Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_species_with_templates() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("species_and_templates.sb"),
|
||||||
|
r#"
|
||||||
|
species Human {
|
||||||
|
lifespan: 70..90
|
||||||
|
}
|
||||||
|
|
||||||
|
template Hero {
|
||||||
|
courage: 0.9
|
||||||
|
strength: 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
character Alice: Human from Hero {
|
||||||
|
age: 7
|
||||||
|
name: "Alice"
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = Command::new(sb_binary())
|
||||||
|
.arg("validate")
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute sb validate");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Character with both species and templates should validate. Stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("✓ Validation successful"));
|
||||||
|
assert!(stdout.contains("Characters: 1"));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user