#![deny(missing_docs)]
use std::{
error::Error,
fmt::{Display, Formatter, Result as FmtResult},
};
#[derive(Debug)]
pub struct LayerEntry {
pub name: Box<str>,
pub path: Box<str>,
}
#[derive(Debug)]
pub struct LayerGroup {
pub internal_name: Box<str>,
pub display_name: Option<Box<str>>,
pub entries: Vec<LayerEntry>,
}
#[derive(Debug)]
pub struct CharacterDefinition {
pub layers: Vec<LayerGroup>,
}
#[derive(Debug)]
pub enum ParseError {
OrphanVariant {
line: usize,
},
EmptyName {
line: usize,
},
LayerWithoutEntries {
line: usize,
},
DuplicateName {
name: Box<str>,
line: usize,
},
}
impl Display for ParseError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
match self {
Self::OrphanVariant { line } => {
write!(formatter, "line {line}: variant declared before any layer")
}
Self::EmptyName { line } => {
write!(formatter, "line {line}: layer without internal name")
}
Self::LayerWithoutEntries { line } => {
write!(formatter, "line {line}: layer has no variants")
}
Self::DuplicateName { name, line } => {
write!(formatter, "line {line}: duplicate layer name '{name}'")
}
}
}
}
impl Error for ParseError {}
enum Line<'a> {
Layer {
internal_name: &'a str,
display_name: Option<&'a str>,
},
Variant {
name: &'a str,
path: &'a str,
},
}
fn classify(line: &str) -> Option<Line<'_>> {
let line = line
.split_once('#')
.map_or(line, |(entry, _comment)| entry)
.trim();
if line.is_empty() {
return None;
}
if let Some(entry) = line.strip_prefix('-') {
let entry = entry.trim();
let (name, path) = entry.split_once(':').map_or((entry, ""), |(name, path)| {
(name.trim_end(), path.trim_start())
});
return Some(Line::Variant { name, path });
}
let (internal_name, display_name) = line
.split_once(':')
.map_or((line, None), |(internal, display)| {
(internal.trim(), Some(display.trim()))
});
Some(Line::Layer {
internal_name,
display_name,
})
}
impl CharacterDefinition {
pub fn parse(input: &str) -> Self {
let mut layers = Vec::new();
let mut current_group: Option<LayerGroup> = None;
for line in input.lines() {
match classify(line) {
None => {}
Some(Line::Variant { name, path }) => {
if let Some(group) = &mut current_group {
group.entries.push(LayerEntry {
name: name.into(),
path: path.into(),
});
}
}
Some(Line::Layer {
internal_name,
display_name,
}) => {
if let Some(group) = current_group.take() {
layers.push(group);
}
current_group = Some(LayerGroup {
internal_name: internal_name.into(),
display_name: display_name.map(Into::into),
entries: Vec::new(),
});
}
}
}
if let Some(group) = current_group.take() {
layers.push(group);
}
Self { layers }
}
pub fn try_parse(input: &str) -> Result<Self, ParseError> {
let mut layers: Vec<LayerGroup> = Vec::new();
let mut current_group: Option<(LayerGroup, usize)> = None;
for (index, line) in input.lines().enumerate() {
let number = index + 1;
match classify(line) {
None => {}
Some(Line::Variant { name, path }) => {
let Some((group, _declared)) = &mut current_group else {
return Err(ParseError::OrphanVariant { line: number });
};
group.entries.push(LayerEntry {
name: name.into(),
path: path.into(),
});
}
Some(Line::Layer {
internal_name,
display_name,
}) => {
if internal_name.is_empty() {
return Err(ParseError::EmptyName { line: number });
}
if let Some((group, declared)) = current_group.take() {
if group.entries.is_empty() {
return Err(ParseError::LayerWithoutEntries { line: declared });
}
layers.push(group);
}
if layers
.iter()
.any(|group| group.internal_name.as_ref() == internal_name)
{
return Err(ParseError::DuplicateName {
name: internal_name.into(),
line: number,
});
}
current_group = Some((
LayerGroup {
internal_name: internal_name.into(),
display_name: display_name.map(Into::into),
entries: Vec::new(),
},
number,
));
}
}
}
if let Some((group, declared)) = current_group.take() {
if group.entries.is_empty() {
return Err(ParseError::LayerWithoutEntries { line: declared });
}
layers.push(group);
}
Ok(Self { layers })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_basic_definition() {
let input = r"
base
- Default: base.png
expression: Mood
- Happy: happy.png
- Sad: sad.png
";
let def = CharacterDefinition::parse(input);
assert_eq!(def.layers.len(), 2);
assert_eq!(def.layers[0].internal_name.as_ref(), "base");
assert_eq!(def.layers[1].display_name.as_deref(), Some("Mood"));
assert_eq!(def.layers[1].entries[1].name.as_ref(), "Sad");
}
#[test]
fn handles_empty_paths() {
let input = r"
outfit
- Shirt: shirt.png
- None
";
let def = CharacterDefinition::parse(input);
assert!(def.layers[0].entries[1].path.is_empty());
}
#[test]
fn ignores_comments() {
let input = r"
# This is a comment
base # Base layer comment
- Default: base.png # Default variant
# Expression group
expression: Mood
- Happy: happy.png
- Sad: sad.png # Disabled below
- None
";
let def = CharacterDefinition::parse(input);
assert_eq!(def.layers.len(), 2);
assert_eq!(def.layers[0].internal_name.as_ref(), "base");
assert!(def.layers[1].entries[2].path.is_empty());
}
#[test]
fn handles_inline_comments() {
let input = r"
base # Important base layer
- Default: base.png # Main variant
- Alternate: alternate.png
";
let def = CharacterDefinition::parse(input);
assert_eq!(def.layers[0].entries[0].name.as_ref(), "Default");
assert_eq!(def.layers[0].entries[1].path.as_ref(), "alternate.png");
}
#[test]
fn try_parse_accepts_valid_input() {
let input = r"
base
- Default: base.png
";
let def = CharacterDefinition::try_parse(input).unwrap();
assert_eq!(def.layers.len(), 1);
}
#[test]
fn try_parse_rejects_orphan_variant() {
let input = r"
- Default: base.png
";
assert!(matches!(
CharacterDefinition::try_parse(input),
Err(ParseError::OrphanVariant { line: 2 })
));
}
#[test]
fn try_parse_rejects_empty_name() {
let input = r"
: Mood
- Happy: happy.png
";
assert!(matches!(
CharacterDefinition::try_parse(input),
Err(ParseError::EmptyName { line: 2 })
));
}
#[test]
fn try_parse_rejects_layer_without_entries() {
let input = r"
base
- Default: base.png
expression: Mood
";
assert!(matches!(
CharacterDefinition::try_parse(input),
Err(ParseError::LayerWithoutEntries { line: 5 })
));
}
#[test]
fn try_parse_rejects_duplicate_name() {
let input = r"
base
- Default: base.png
base
- Alternate: alternate.png
";
assert!(matches!(
CharacterDefinition::try_parse(input),
Err(ParseError::DuplicateName { line: 5, .. })
));
}
}