Skip to main content

dialogi/
lib.rs

1#![deny(missing_docs)]
2
3//! A crate to parse dialog lines from a simple markdown inspired format.
4
5use header_parsing::parse_header;
6use thiserror::Error;
7
8use std::{
9    collections::{HashMap, HashSet},
10    fs::{File, read_dir},
11    hash::Hash,
12    io::{BufRead, BufReader, Error as IoError},
13    mem,
14    path::{Path, PathBuf},
15};
16
17/// A single dialog line consisting of actions to be advanced when the line is displayed, and the text itself.
18#[derive(Clone, Debug)]
19pub struct DialogLine<P> {
20    /// The text of the dialog line.
21    pub text: Box<str>,
22    /// The actions advanced by the dialog line, identified by parameters of type `P`.
23    pub actions: HashSet<P>,
24}
25
26/// A full dialog block consisting of the name of the talker, the lines of the dialog, and some final actions to be advanced when the whole text box is fully displayed.
27#[derive(Clone, Debug)]
28pub struct DialogBlock<P> {
29    /// The speaker name of the dialog block.
30    pub name: Box<str>,
31    /// The text lines of the dialog block.
32    pub lines: Vec<DialogLine<P>>,
33    /// The actions advanced by the dialog block, identified by parameters of type `P`.
34    pub final_actions: HashSet<P>,
35}
36
37impl<P> DialogBlock<P> {
38    fn new() -> Self {
39        Self {
40            name: "".into(),
41            lines: Vec::new(),
42            final_actions: HashSet::new(),
43        }
44    }
45
46    fn is_empty(&self) -> bool {
47        self.name.is_empty() && self.lines.is_empty() && self.final_actions.is_empty()
48    }
49
50    /// The text lines of a dialog block as string references.
51    pub fn lines(&self) -> impl Iterator<Item = &str> {
52        self.lines.iter().map(|line| line.text.as_ref())
53    }
54}
55
56/// A parameter to define an action of the dialog.
57pub trait DialogParameter: Sized {
58    /// The parameter context, which might be important for creation by name.
59    type Context;
60    /// The method to create a parameter. It might access some context and returns some new dialog parameter or none.
61    fn create(name: &str, context: &mut Self::Context) -> Option<Self>;
62}
63
64/// A change to define an how actions are applied.
65pub trait DialogChange: Sized {
66    /// The parameter to apply this change.
67    type Parameter: DialogParameter + Clone + Eq + Hash;
68
69    /// Creates the change of the parameter back to the default value.
70    fn default_change(parameter: Self::Parameter) -> Self;
71
72    /// Creates a change of the parameter to the specified value.
73    fn value_change(
74        parameter: Self::Parameter,
75        value: &str,
76        context: &mut <<Self as DialogChange>::Parameter as DialogParameter>::Context,
77    ) -> Self;
78}
79
80/// Defines a full sequential dialog as a sequence of dialog blocks.
81pub struct DialogSequence<C, P> {
82    /// The sequence of dialog blocks.
83    pub blocks: Vec<DialogBlock<P>>,
84    /// The changes to be applied by the dialog parameters.
85    pub changes: HashMap<P, Vec<C>>,
86}
87
88/// A trait to parse dialog sequences into, identified by a name.
89pub trait DialogMap<C: DialogChange>: Default {
90    /// Adds a single sequential dialog into the dialog map.
91    fn add(&mut self, key: Vec<Box<str>>, value: DialogSequence<C, C::Parameter>);
92}
93
94impl<C: DialogChange> DialogMap<C> for HashMap<Vec<Box<str>>, DialogSequence<C, C::Parameter>> {
95    fn add(&mut self, key: Vec<Box<str>>, value: DialogSequence<C, C::Parameter>) {
96        self.insert(key, value);
97    }
98}
99
100impl<C: DialogChange> DialogMap<C> for Vec<DialogSequence<C, C::Parameter>> {
101    fn add(&mut self, _key: Vec<Box<str>>, value: DialogSequence<C, C::Parameter>) {
102        self.push(value);
103    }
104}
105
106/// An error type returned when parsing a the dialog structure fails.
107#[derive(Debug, Error)]
108pub enum ParsingError {
109    /// Colon parameters are not allowed to have a value supplied.
110    #[error("Colon parameters are not allowed to have a value supplied")]
111    ColonParameterWithValues,
112    /// Error while opening story file.
113    #[error("Error while opening story file {path}: {source}")]
114    OpeningError {
115        /// The path to the story file.
116        path: PathBuf,
117        /// The underlying IO error.
118        source: IoError,
119    },
120    /// Error while reading story file.
121    #[error("Error while reading story file {path}: {source}")]
122    ReadingError {
123        /// The path to the story file.
124        path: PathBuf,
125        /// The underlying IO error.
126        source: IoError,
127    },
128    /// Subheader found without a matching header.
129    #[error("Subheader found without a matching header")]
130    SubheaderWithoutHeader,
131    /// Invalid dialog format.
132    #[error("Invalid dialog format")]
133    InvalidIndentation,
134    /// Invalid indentation level.
135    #[error("Invalid indentation level")]
136    IndentationTooHigh,
137    /// Default parameters cannot have a value supplied.
138    #[error("Default parameters cannot have a value supplied")]
139    DefaultParameterWithValue,
140    /// Duplicate definition of change.
141    #[error("Duplicate definition of change: {0}")]
142    DuplicateDefinitionOfChange(Box<str>),
143}
144
145impl<C: DialogChange> DialogSequence<C, C::Parameter> {
146    fn new() -> Self {
147        Self {
148            blocks: Vec::new(),
149            changes: HashMap::new(),
150        }
151    }
152
153    /// Loads a new dialog file from a specified path and returns a new text map.
154    ///
155    /// If the path is a folder, it will be scanned for `.pk` files and sub folders.
156    /// If the path is a file, it will be parsed as directly using the `.pk` parsing format.
157    pub fn map_from_path<M: DialogMap<C>>(
158        path: &Path,
159        context: &mut <C::Parameter as DialogParameter>::Context,
160    ) -> Result<M, ParsingError> {
161        let mut text_map = M::default();
162        Self::fill_map_from_path(path, &mut text_map, context)?;
163        Ok(text_map)
164    }
165
166    /// Loads a new dialog file from a specified path into the supplied text map.
167    ///
168    /// If the path is a folder, it will be scanned for `.pk` files and sub folders.
169    /// If the path is a file, it will be parsed as directly using the `.pk` parsing format.
170    pub fn fill_map_from_path<M: DialogMap<C>>(
171        path: &Path,
172        text_map: &mut M,
173        context: &mut <C::Parameter as DialogParameter>::Context,
174    ) -> Result<(), ParsingError> {
175        Self::named_fill_map_from_path(path, text_map, Vec::new(), context)
176    }
177
178    fn named_fill_map_from_path<M: DialogMap<C>>(
179        path: &Path,
180        text_map: &mut M,
181        default_name: Vec<Box<str>>,
182        context: &mut <C::Parameter as DialogParameter>::Context,
183    ) -> Result<(), ParsingError> {
184        let Ok(dirs) = read_dir(path) else {
185            return Self::fill_map_from_file(path, default_name, text_map, context);
186        };
187
188        for entry in dirs {
189            let Ok(dir) = entry else {
190                eprintln!("Warning: failed to read entry in {}", path.display());
191                continue;
192            };
193            Self::try_fill_submap_from_path(&dir.path(), default_name.clone(), text_map, context)?;
194        }
195
196        Ok(())
197    }
198
199    fn try_fill_submap_from_path<M: DialogMap<C>>(
200        path: &Path,
201        mut relative_name: Vec<Box<str>>,
202        text_map: &mut M,
203        context: &mut <C::Parameter as DialogParameter>::Context,
204    ) -> Result<(), ParsingError> {
205        let Some(name) = path.file_stem() else {
206            return Ok(());
207        };
208
209        let Some(name) = name.to_str() else {
210            return Ok(());
211        };
212
213        relative_name.push(name.into());
214        Self::named_fill_map_from_path(path, text_map, relative_name, context)
215    }
216
217    fn handle_content_line(
218        &mut self,
219        line: &str,
220        current_block: &mut DialogBlock<C::Parameter>,
221        path: &mut Vec<Box<str>>,
222        context: &mut <C::Parameter as DialogParameter>::Context,
223    ) -> Result<(), ParsingError> {
224        if line.trim().is_empty() {
225            if !current_block.is_empty() {
226                self.blocks
227                    .push(mem::replace(current_block, DialogBlock::new()));
228            }
229
230            return Ok(());
231        }
232
233        let mut spaces = 0;
234        let mut chars = line.chars();
235        let mut c = chars.next().unwrap();
236        while c == ' ' {
237            spaces += 1;
238            c = chars.next().unwrap();
239        }
240        let first = c;
241
242        if first == '-' {
243            if spaces % 2 != 0 {
244                return Err(ParsingError::InvalidIndentation);
245            }
246            let level = spaces / 2;
247            if level > path.len() {
248                return Err(ParsingError::IndentationTooHigh);
249            }
250            while path.len() > level {
251                path.pop();
252            }
253            let line = line[(spaces + 1)..].trim();
254            let (name_end, value) = line
255                .split_once(' ')
256                .map_or((line, ""), |(name, value)| (name.trim(), value.trim()));
257            let default = name_end.ends_with('!');
258
259            if default && !value.is_empty() {
260                return Err(ParsingError::DefaultParameterWithValue);
261            }
262
263            let colon_end = name_end.ends_with(':');
264
265            let name_end: Box<str> = if default || colon_end {
266                &name_end[0..(name_end.len() - 1)]
267            } else {
268                name_end
269            }
270            .into();
271
272            if colon_end {
273                if !value.is_empty() {
274                    return Err(ParsingError::ColonParameterWithValues);
275                }
276
277                path.push(name_end);
278                return Ok(());
279            }
280
281            let parameter_name = path.iter().rev().fold(name_end.clone(), |name, element| {
282                format!("{element}:{name}").into()
283            });
284
285            path.push(name_end);
286
287            let Some(parameter) = DialogParameter::create(&parameter_name, context) else {
288                return Ok(());
289            };
290
291            if current_block.final_actions.contains(&parameter) {
292                return Err(ParsingError::DuplicateDefinitionOfChange(parameter_name));
293            }
294
295            let change = if default {
296                DialogChange::default_change(parameter.clone())
297            } else {
298                DialogChange::value_change(parameter.clone(), value, context)
299            };
300
301            if let Some(map) = self.changes.get_mut(&parameter) {
302                map.push(change);
303            } else {
304                self.changes.insert(parameter.clone(), vec![change]);
305            }
306
307            current_block.final_actions.insert(parameter);
308
309            return Ok(());
310        }
311
312        path.clear();
313
314        let (Some((name, text)), 0) = (line.split_once(':'), spaces) else {
315            current_block.lines.push(DialogLine {
316                text: line.trim().into(),
317                actions: mem::take(&mut current_block.final_actions),
318            });
319
320            return Ok(());
321        };
322
323        let text = text.trim();
324
325        let parameters = if current_block.is_empty() {
326            mem::take(&mut current_block.final_actions)
327        } else {
328            let old = mem::replace(current_block, DialogBlock::new());
329            self.blocks.push(old);
330            HashSet::new()
331        };
332
333        current_block.name = name.trim().into();
334        if text.is_empty() {
335            current_block.final_actions = parameters;
336        } else {
337            current_block.lines = vec![DialogLine {
338                text: text.into(),
339                actions: parameters,
340            }];
341        }
342
343        Ok(())
344    }
345
346    fn fill_map_from_file<M: DialogMap<C>>(
347        path: &Path,
348        default_name: Vec<Box<str>>,
349        text_map: &mut M,
350        context: &mut <C::Parameter as DialogParameter>::Context,
351    ) -> Result<(), ParsingError> {
352        let valid_path = path.extension().is_some_and(|e| e == "pk");
353
354        if !valid_path {
355            return Ok(());
356        }
357
358        let story_file = File::open(path).map_err(|source| ParsingError::OpeningError {
359            path: path.to_path_buf(),
360            source,
361        })?;
362        let mut current_block = DialogBlock::new();
363        let mut current_sequence = Self::new();
364        let mut name = Vec::new();
365        let mut parameter_path = Vec::new();
366
367        for line in BufReader::new(story_file).lines() {
368            let line = line.map_err(|source| ParsingError::ReadingError {
369                path: path.to_path_buf(),
370                source,
371            })?;
372
373            if let Some(success) = parse_header(&mut name, &line) {
374                let Ok(changes) = success else {
375                    return Err(ParsingError::SubheaderWithoutHeader);
376                };
377
378                if !current_block.is_empty() {
379                    current_sequence.blocks.push(current_block);
380                    current_block = DialogBlock::new();
381                }
382
383                if !current_sequence.blocks.is_empty() {
384                    let mut new_name = default_name.clone();
385                    new_name.extend(changes.path.clone());
386                    text_map.add(new_name, current_sequence);
387                }
388                current_sequence = Self::new();
389
390                changes.apply();
391
392                continue;
393            }
394
395            current_sequence.handle_content_line(
396                &line,
397                &mut current_block,
398                &mut parameter_path,
399                context,
400            )?;
401        }
402
403        if !current_block.is_empty() {
404            current_sequence.blocks.push(current_block);
405        }
406
407        if !current_sequence.blocks.is_empty() {
408            let mut new_name = default_name;
409            new_name.extend(name);
410            text_map.add(new_name, current_sequence);
411        }
412
413        Ok(())
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use std::io::Write as _;
421
422    #[derive(Clone, Debug, PartialEq, Eq, Hash)]
423    struct TestParameter(Box<str>);
424
425    impl DialogParameter for TestParameter {
426        type Context = ();
427        fn create(name: &str, _context: &mut ()) -> Option<Self> {
428            Some(TestParameter(name.into()))
429        }
430    }
431
432    #[derive(Debug)]
433    #[allow(dead_code)]
434    enum TestChange {
435        Default(TestParameter),
436        Value(TestParameter, Box<str>),
437    }
438
439    impl DialogChange for TestChange {
440        type Parameter = TestParameter;
441
442        fn default_change(parameter: TestParameter) -> Self {
443            TestChange::Default(parameter)
444        }
445
446        fn value_change(parameter: TestParameter, value: &str, _context: &mut ()) -> Self {
447            TestChange::Value(parameter, value.into())
448        }
449    }
450
451    type TestSequence = DialogSequence<TestChange, TestParameter>;
452    type TestMap = Vec<TestSequence>;
453
454    use std::sync::atomic::{AtomicU32, Ordering};
455
456    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
457
458    fn parse_file(content: &str) -> Result<TestMap, ParsingError> {
459        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
460        let dir = std::env::temp_dir().join(format!("dialogi_test_{id}"));
461        std::fs::create_dir_all(&dir).unwrap();
462        let path = dir.join("test.pk");
463        let mut file = File::create(&path).unwrap();
464        file.write_all(content.as_bytes()).unwrap();
465        let result = TestSequence::map_from_path::<TestMap>(&path, &mut ());
466        std::fs::remove_file(&path).unwrap();
467        let _ = std::fs::remove_dir(&dir);
468        result
469    }
470
471    #[test]
472    fn simple_text_blocks() {
473        let sequences = parse_file("# Scene\n\nHello world\n\nSecond block").unwrap();
474        assert_eq!(sequences.len(), 1);
475        assert_eq!(sequences[0].blocks.len(), 2);
476        assert_eq!(sequences[0].blocks[0].name.as_ref(), "");
477        assert_eq!(sequences[0].blocks[0].lines[0].text.as_ref(), "Hello world");
478        assert_eq!(
479            sequences[0].blocks[1].lines[0].text.as_ref(),
480            "Second block"
481        );
482    }
483
484    #[test]
485    fn talker_with_text() {
486        let sequences = parse_file("# Scene\n\nAlice: Hi!\n\nBob: Hello").unwrap();
487        assert_eq!(sequences[0].blocks.len(), 2);
488        assert_eq!(sequences[0].blocks[0].name.as_ref(), "Alice");
489        assert_eq!(sequences[0].blocks[0].lines[0].text.as_ref(), "Hi!");
490        assert_eq!(sequences[0].blocks[1].name.as_ref(), "Bob");
491    }
492
493    #[test]
494    fn talker_multiline() {
495        let sequences = parse_file("# Scene\n\nAlice:\nLine 1\nLine 2").unwrap();
496        assert_eq!(sequences[0].blocks[0].name.as_ref(), "Alice");
497        assert_eq!(sequences[0].blocks[0].lines.len(), 2);
498        assert_eq!(sequences[0].blocks[0].lines[0].text.as_ref(), "Line 1");
499        assert_eq!(sequences[0].blocks[0].lines[1].text.as_ref(), "Line 2");
500    }
501
502    #[test]
503    fn events_with_values() {
504        let sequences = parse_file("# Scene\n\n- Mood happy\nAlice: Hi!").unwrap();
505        assert!(
506            sequences[0]
507                .changes
508                .contains_key(&TestParameter("Mood".into()))
509        );
510        assert!(
511            sequences[0].blocks[0]
512                .final_actions
513                .contains(&TestParameter("Mood".into()))
514        );
515        assert_eq!(sequences[0].blocks[1].name.as_ref(), "Alice");
516    }
517
518    #[test]
519    fn default_event() {
520        let sequences = parse_file("# Scene\n\n- Mood!\nSome text").unwrap();
521        assert!(
522            sequences[0]
523                .changes
524                .contains_key(&TestParameter("Mood".into()))
525        );
526    }
527
528    #[test]
529    fn hierarchical_event_path() {
530        let sequences =
531            parse_file("# Scene\n\n- Path:\n  - To:\n    - Param Value\nSome text").unwrap();
532        assert!(
533            sequences[0]
534                .changes
535                .contains_key(&TestParameter("Path:To:Param".into()))
536        );
537    }
538
539    #[test]
540    fn multiple_headers() {
541        let sequences = parse_file("# Scene 1\n\nText 1\n\n# Scene 2\n\nText 2").unwrap();
542        assert_eq!(sequences.len(), 2);
543    }
544
545    #[test]
546    fn invalid_indentation() {
547        let result = parse_file("# Scene\n\n - Param Value");
548        assert!(matches!(result, Err(ParsingError::InvalidIndentation)));
549    }
550
551    #[test]
552    fn indentation_too_high() {
553        let result = parse_file("# Scene\n\n    - Param Value");
554        assert!(matches!(result, Err(ParsingError::IndentationTooHigh)));
555    }
556
557    #[test]
558    fn default_parameter_with_value() {
559        let result = parse_file("# Scene\n\n- Param! Value");
560        assert!(matches!(
561            result,
562            Err(ParsingError::DefaultParameterWithValue)
563        ));
564    }
565
566    #[test]
567    fn empty_lines_separate_blocks() {
568        let sequences = parse_file("# Scene\n\nLine 1\n\nLine 2\n\nLine 3").unwrap();
569        assert_eq!(sequences[0].blocks.len(), 3);
570    }
571
572    #[test]
573    fn narrator_text_has_empty_name() {
574        let sequences = parse_file("# Scene\n\nNarrator text here").unwrap();
575        assert_eq!(sequences[0].blocks[0].name.as_ref(), "");
576    }
577
578    #[test]
579    fn colon_parameter_with_value_rejected() {
580        let result = parse_file("# Scene\n\n- Path: Value");
581        assert!(matches!(
582            result,
583            Err(ParsingError::ColonParameterWithValues)
584        ));
585    }
586
587    #[test]
588    fn nonexistent_file() {
589        let result =
590            TestSequence::map_from_path::<TestMap>(Path::new("/nonexistent/test.pk"), &mut ());
591        assert!(matches!(result, Err(ParsingError::OpeningError { .. })));
592    }
593
594    #[test]
595    fn non_pk_file_ignored() {
596        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
597        let dir = std::env::temp_dir().join(format!("dialogi_test_ext_{id}"));
598        std::fs::create_dir_all(&dir).unwrap();
599        let path = dir.join("test.txt");
600        std::fs::write(&path, "# Scene\n\nHello").unwrap();
601        let result = TestSequence::map_from_path::<TestMap>(&path, &mut ()).unwrap();
602        assert!(result.is_empty());
603        std::fs::remove_file(&path).unwrap();
604        let _ = std::fs::remove_dir(&dir);
605    }
606}