#![deny(missing_docs)]
use header_parsing::parse_header;
use thiserror::Error;
use std::{
collections::{HashMap, HashSet},
fs::{File, read_dir},
hash::Hash,
io::{BufRead, BufReader, Error as IoError},
mem,
path::{Path, PathBuf},
};
#[derive(Clone, Debug)]
pub struct DialogLine<P> {
pub text: Box<str>,
pub actions: HashSet<P>,
}
#[derive(Clone, Debug)]
pub struct DialogBlock<P> {
pub name: Box<str>,
pub lines: Vec<DialogLine<P>>,
pub final_actions: HashSet<P>,
}
impl<P> DialogBlock<P> {
fn new() -> Self {
Self {
name: "".into(),
lines: Vec::new(),
final_actions: HashSet::new(),
}
}
fn is_empty(&self) -> bool {
self.name.is_empty() && self.lines.is_empty() && self.final_actions.is_empty()
}
pub fn lines(&self) -> impl Iterator<Item = &str> {
self.lines.iter().map(|line| line.text.as_ref())
}
}
pub trait DialogParameter: Sized {
type Context;
fn create(name: &str, context: &mut Self::Context) -> Option<Self>;
}
pub trait DialogChange: Sized {
type Parameter: DialogParameter + Clone + Eq + Hash;
fn default_change(parameter: Self::Parameter) -> Self;
fn value_change(
parameter: Self::Parameter,
value: &str,
context: &mut <<Self as DialogChange>::Parameter as DialogParameter>::Context,
) -> Self;
}
pub struct DialogSequence<C, P> {
pub blocks: Vec<DialogBlock<P>>,
pub changes: HashMap<P, Vec<C>>,
}
pub trait DialogMap<C: DialogChange>: Default {
fn add(&mut self, key: Vec<Box<str>>, value: DialogSequence<C, C::Parameter>);
}
impl<C: DialogChange> DialogMap<C> for HashMap<Vec<Box<str>>, DialogSequence<C, C::Parameter>> {
fn add(&mut self, key: Vec<Box<str>>, value: DialogSequence<C, C::Parameter>) {
self.insert(key, value);
}
}
impl<C: DialogChange> DialogMap<C> for Vec<DialogSequence<C, C::Parameter>> {
fn add(&mut self, _key: Vec<Box<str>>, value: DialogSequence<C, C::Parameter>) {
self.push(value);
}
}
#[derive(Debug, Error)]
pub enum ParsingError {
#[error("Colon parameters are not allowed to have a value supplied")]
ColonParameterWithValues,
#[error("Error while opening story file {path}: {source}")]
OpeningError {
path: PathBuf,
source: IoError,
},
#[error("Error while reading story file {path}: {source}")]
ReadingError {
path: PathBuf,
source: IoError,
},
#[error("Subheader found without a matching header")]
SubheaderWithoutHeader,
#[error("Invalid dialog format")]
InvalidIndentation,
#[error("Invalid indentation level")]
IndentationTooHigh,
#[error("Default parameters cannot have a value supplied")]
DefaultParameterWithValue,
#[error("Duplicate definition of change: {0}")]
DuplicateDefinitionOfChange(Box<str>),
}
impl<C: DialogChange> DialogSequence<C, C::Parameter> {
fn new() -> Self {
Self {
blocks: Vec::new(),
changes: HashMap::new(),
}
}
pub fn map_from_path<M: DialogMap<C>>(
path: &Path,
context: &mut <C::Parameter as DialogParameter>::Context,
) -> Result<M, ParsingError> {
let mut text_map = M::default();
Self::fill_map_from_path(path, &mut text_map, context)?;
Ok(text_map)
}
pub fn fill_map_from_path<M: DialogMap<C>>(
path: &Path,
text_map: &mut M,
context: &mut <C::Parameter as DialogParameter>::Context,
) -> Result<(), ParsingError> {
Self::named_fill_map_from_path(path, text_map, Vec::new(), context)
}
fn named_fill_map_from_path<M: DialogMap<C>>(
path: &Path,
text_map: &mut M,
default_name: Vec<Box<str>>,
context: &mut <C::Parameter as DialogParameter>::Context,
) -> Result<(), ParsingError> {
let Ok(dirs) = read_dir(path) else {
return Self::fill_map_from_file(path, default_name, text_map, context);
};
for entry in dirs {
let Ok(dir) = entry else {
eprintln!("Warning: failed to read entry in {}", path.display());
continue;
};
Self::try_fill_submap_from_path(&dir.path(), default_name.clone(), text_map, context)?;
}
Ok(())
}
fn try_fill_submap_from_path<M: DialogMap<C>>(
path: &Path,
mut relative_name: Vec<Box<str>>,
text_map: &mut M,
context: &mut <C::Parameter as DialogParameter>::Context,
) -> Result<(), ParsingError> {
let Some(name) = path.file_stem() else {
return Ok(());
};
let Some(name) = name.to_str() else {
return Ok(());
};
relative_name.push(name.into());
Self::named_fill_map_from_path(path, text_map, relative_name, context)
}
fn handle_content_line(
&mut self,
line: &str,
current_block: &mut DialogBlock<C::Parameter>,
path: &mut Vec<Box<str>>,
context: &mut <C::Parameter as DialogParameter>::Context,
) -> Result<(), ParsingError> {
if line.trim().is_empty() {
if !current_block.is_empty() {
self.blocks
.push(mem::replace(current_block, DialogBlock::new()));
}
return Ok(());
}
let mut spaces = 0;
let mut chars = line.chars();
let mut c = chars.next().unwrap();
while c == ' ' {
spaces += 1;
c = chars.next().unwrap();
}
let first = c;
if first == '-' {
if spaces % 2 != 0 {
return Err(ParsingError::InvalidIndentation);
}
let level = spaces / 2;
if level > path.len() {
return Err(ParsingError::IndentationTooHigh);
}
while path.len() > level {
path.pop();
}
let line = line[(spaces + 1)..].trim();
let (name_end, value) = line
.split_once(' ')
.map_or((line, ""), |(name, value)| (name.trim(), value.trim()));
let default = name_end.ends_with('!');
if default && !value.is_empty() {
return Err(ParsingError::DefaultParameterWithValue);
}
let colon_end = name_end.ends_with(':');
let name_end: Box<str> = if default || colon_end {
&name_end[0..(name_end.len() - 1)]
} else {
name_end
}
.into();
if colon_end {
if !value.is_empty() {
return Err(ParsingError::ColonParameterWithValues);
}
path.push(name_end);
return Ok(());
}
let parameter_name = path.iter().rev().fold(name_end.clone(), |name, element| {
format!("{element}:{name}").into()
});
path.push(name_end);
let Some(parameter) = DialogParameter::create(¶meter_name, context) else {
return Ok(());
};
if current_block.final_actions.contains(¶meter) {
return Err(ParsingError::DuplicateDefinitionOfChange(parameter_name));
}
let change = if default {
DialogChange::default_change(parameter.clone())
} else {
DialogChange::value_change(parameter.clone(), value, context)
};
if let Some(map) = self.changes.get_mut(¶meter) {
map.push(change);
} else {
self.changes.insert(parameter.clone(), vec![change]);
}
current_block.final_actions.insert(parameter);
return Ok(());
}
path.clear();
let (Some((name, text)), 0) = (line.split_once(':'), spaces) else {
current_block.lines.push(DialogLine {
text: line.trim().into(),
actions: mem::take(&mut current_block.final_actions),
});
return Ok(());
};
let text = text.trim();
let parameters = if current_block.is_empty() {
mem::take(&mut current_block.final_actions)
} else {
let old = mem::replace(current_block, DialogBlock::new());
self.blocks.push(old);
HashSet::new()
};
current_block.name = name.trim().into();
if text.is_empty() {
current_block.final_actions = parameters;
} else {
current_block.lines = vec![DialogLine {
text: text.into(),
actions: parameters,
}];
}
Ok(())
}
fn fill_map_from_file<M: DialogMap<C>>(
path: &Path,
default_name: Vec<Box<str>>,
text_map: &mut M,
context: &mut <C::Parameter as DialogParameter>::Context,
) -> Result<(), ParsingError> {
let valid_path = path.extension().is_some_and(|e| e == "pk");
if !valid_path {
return Ok(());
}
let story_file = File::open(path).map_err(|source| ParsingError::OpeningError {
path: path.to_path_buf(),
source,
})?;
let mut current_block = DialogBlock::new();
let mut current_sequence = Self::new();
let mut name = Vec::new();
let mut parameter_path = Vec::new();
for line in BufReader::new(story_file).lines() {
let line = line.map_err(|source| ParsingError::ReadingError {
path: path.to_path_buf(),
source,
})?;
if let Some(success) = parse_header(&mut name, &line) {
let Ok(changes) = success else {
return Err(ParsingError::SubheaderWithoutHeader);
};
if !current_block.is_empty() {
current_sequence.blocks.push(current_block);
current_block = DialogBlock::new();
}
if !current_sequence.blocks.is_empty() {
let mut new_name = default_name.clone();
new_name.extend(changes.path.clone());
text_map.add(new_name, current_sequence);
}
current_sequence = Self::new();
changes.apply();
continue;
}
current_sequence.handle_content_line(
&line,
&mut current_block,
&mut parameter_path,
context,
)?;
}
if !current_block.is_empty() {
current_sequence.blocks.push(current_block);
}
if !current_sequence.blocks.is_empty() {
let mut new_name = default_name;
new_name.extend(name);
text_map.add(new_name, current_sequence);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct TestParameter(Box<str>);
impl DialogParameter for TestParameter {
type Context = ();
fn create(name: &str, _context: &mut ()) -> Option<Self> {
Some(TestParameter(name.into()))
}
}
#[derive(Debug)]
#[allow(dead_code)]
enum TestChange {
Default(TestParameter),
Value(TestParameter, Box<str>),
}
impl DialogChange for TestChange {
type Parameter = TestParameter;
fn default_change(parameter: TestParameter) -> Self {
TestChange::Default(parameter)
}
fn value_change(parameter: TestParameter, value: &str, _context: &mut ()) -> Self {
TestChange::Value(parameter, value.into())
}
}
type TestSequence = DialogSequence<TestChange, TestParameter>;
type TestMap = Vec<TestSequence>;
use std::sync::atomic::{AtomicU32, Ordering};
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
fn parse_file(content: &str) -> Result<TestMap, ParsingError> {
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("dialogi_test_{id}"));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.pk");
let mut file = File::create(&path).unwrap();
file.write_all(content.as_bytes()).unwrap();
let result = TestSequence::map_from_path::<TestMap>(&path, &mut ());
std::fs::remove_file(&path).unwrap();
let _ = std::fs::remove_dir(&dir);
result
}
#[test]
fn simple_text_blocks() {
let sequences = parse_file("# Scene\n\nHello world\n\nSecond block").unwrap();
assert_eq!(sequences.len(), 1);
assert_eq!(sequences[0].blocks.len(), 2);
assert_eq!(sequences[0].blocks[0].name.as_ref(), "");
assert_eq!(sequences[0].blocks[0].lines[0].text.as_ref(), "Hello world");
assert_eq!(
sequences[0].blocks[1].lines[0].text.as_ref(),
"Second block"
);
}
#[test]
fn talker_with_text() {
let sequences = parse_file("# Scene\n\nAlice: Hi!\n\nBob: Hello").unwrap();
assert_eq!(sequences[0].blocks.len(), 2);
assert_eq!(sequences[0].blocks[0].name.as_ref(), "Alice");
assert_eq!(sequences[0].blocks[0].lines[0].text.as_ref(), "Hi!");
assert_eq!(sequences[0].blocks[1].name.as_ref(), "Bob");
}
#[test]
fn talker_multiline() {
let sequences = parse_file("# Scene\n\nAlice:\nLine 1\nLine 2").unwrap();
assert_eq!(sequences[0].blocks[0].name.as_ref(), "Alice");
assert_eq!(sequences[0].blocks[0].lines.len(), 2);
assert_eq!(sequences[0].blocks[0].lines[0].text.as_ref(), "Line 1");
assert_eq!(sequences[0].blocks[0].lines[1].text.as_ref(), "Line 2");
}
#[test]
fn events_with_values() {
let sequences = parse_file("# Scene\n\n- Mood happy\nAlice: Hi!").unwrap();
assert!(
sequences[0]
.changes
.contains_key(&TestParameter("Mood".into()))
);
assert!(
sequences[0].blocks[0]
.final_actions
.contains(&TestParameter("Mood".into()))
);
assert_eq!(sequences[0].blocks[1].name.as_ref(), "Alice");
}
#[test]
fn default_event() {
let sequences = parse_file("# Scene\n\n- Mood!\nSome text").unwrap();
assert!(
sequences[0]
.changes
.contains_key(&TestParameter("Mood".into()))
);
}
#[test]
fn hierarchical_event_path() {
let sequences =
parse_file("# Scene\n\n- Path:\n - To:\n - Param Value\nSome text").unwrap();
assert!(
sequences[0]
.changes
.contains_key(&TestParameter("Path:To:Param".into()))
);
}
#[test]
fn multiple_headers() {
let sequences = parse_file("# Scene 1\n\nText 1\n\n# Scene 2\n\nText 2").unwrap();
assert_eq!(sequences.len(), 2);
}
#[test]
fn invalid_indentation() {
let result = parse_file("# Scene\n\n - Param Value");
assert!(matches!(result, Err(ParsingError::InvalidIndentation)));
}
#[test]
fn indentation_too_high() {
let result = parse_file("# Scene\n\n - Param Value");
assert!(matches!(result, Err(ParsingError::IndentationTooHigh)));
}
#[test]
fn default_parameter_with_value() {
let result = parse_file("# Scene\n\n- Param! Value");
assert!(matches!(
result,
Err(ParsingError::DefaultParameterWithValue)
));
}
#[test]
fn empty_lines_separate_blocks() {
let sequences = parse_file("# Scene\n\nLine 1\n\nLine 2\n\nLine 3").unwrap();
assert_eq!(sequences[0].blocks.len(), 3);
}
#[test]
fn narrator_text_has_empty_name() {
let sequences = parse_file("# Scene\n\nNarrator text here").unwrap();
assert_eq!(sequences[0].blocks[0].name.as_ref(), "");
}
#[test]
fn colon_parameter_with_value_rejected() {
let result = parse_file("# Scene\n\n- Path: Value");
assert!(matches!(
result,
Err(ParsingError::ColonParameterWithValues)
));
}
#[test]
fn nonexistent_file() {
let result =
TestSequence::map_from_path::<TestMap>(Path::new("/nonexistent/test.pk"), &mut ());
assert!(matches!(result, Err(ParsingError::OpeningError { .. })));
}
#[test]
fn non_pk_file_ignored() {
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("dialogi_test_ext_{id}"));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.txt");
std::fs::write(&path, "# Scene\n\nHello").unwrap();
let result = TestSequence::map_from_path::<TestMap>(&path, &mut ()).unwrap();
assert!(result.is_empty());
std::fs::remove_file(&path).unwrap();
let _ = std::fs::remove_dir(&dir);
}
}