commit b3d810484e38ebb38fc606a7c51599de6269c021 Author: Alex Wright Date: Sun Mar 29 18:55:05 2026 +0100 Initial attempt at a nom parser diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..948271d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,32 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "parse" +version = "0.1.0" +dependencies = [ + "nom", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9ea4c44 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "parse" +version = "0.1.0" +edition = "2024" + +[dependencies] +nom = "7.*" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..708322f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,398 @@ +use nom::IResult; +use nom::branch::{ + alt, +}; +pub use nom::bytes::complete::{ + tag, + tag_no_case, +}; +pub use nom::character::complete::{ + digit1, + space0, + space1, +}; +use nom::combinator::opt; +use nom::sequence::{ + pair, + preceded, +}; +use nom::sequence::tuple; +use std::fmt::{ + Debug, + Display, +}; + +pub fn do_nothing_parser(input: &str) -> IResult<&str, &str> { + Ok((input, "")) +} + +#[derive(Debug)] +struct Time { + pub h: i8, + pub m: i8, +} + +impl Display for Time { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:02}:{:02}", self.h, self.m) + } +} + +#[derive(Debug)] +struct Duration { + pub start: Time, + pub end: Time, +} + +impl Display for Duration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:02}:{:02} - {:02}:{:02}", self.start.h, self.start.m, self.end.h, self.end.m) + } +} + +#[derive(Debug)] +pub struct Amount { + pub volume: i16, +} + +impl Display for Amount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}ml", self.volume) + } +} + +#[derive(Debug)] +pub struct Fed { + pub formula: Option, + pub whole_milk: Option, + pub left: Option, +} + +impl Display for Fed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Fed {}ml", self.formula.as_ref().unwrap().volume) + } +} + +fn parse_time(input: &str) -> IResult<&str, Time> { + let (rest, (h, _, m)) = tuple(( + digit1, + tag(":"), + digit1, + ))(input)?; + Ok((rest, Time { h: h.parse().unwrap(), m: m.parse().unwrap() })) +} + +fn parse_duration(input: &str) -> IResult<&str, Duration> { + let (rest, (start, _, end)) = tuple(( + parse_time, + tuple(( + space0, + tag("-"), + space0, + )), + parse_time, + ))(input)?; + Ok((rest, Duration { start, end })) +} + +fn parse_at_or_at(input: &str) -> IResult<&str, &str> { + let (rest, (_, at, _)) = tuple(( + space0, + alt(( + tag("@"), + tag("at"), + )), + space0, + ))(input)?; + Ok((rest, at)) +} + +fn parse_timestamp(input: &str) -> IResult<&str, Duration> { + let (rest, (_, duration)) = tuple(( + parse_at_or_at, + parse_duration, + ))(input)?; + Ok((rest, duration)) +} + +pub fn parse_amount(input: &str) -> IResult<&str, Amount> { + let (rest, (amount, _ml)) = pair(digit1, tag("ml"))(input)?; + println!("Parsed amount: {}", &amount); + let volume = amount.parse().unwrap(); + Ok((rest, Amount { volume })) +} + +pub fn parse_main_amount(input: &str) -> IResult<&str, Amount> { + let (rest, (amount, _)) = pair( + parse_amount, + opt(alt(( + tag_no_case(" premixed formula"), + tag_no_case(" formula"), + ))), + )(input)?; + Ok((rest, amount)) +} + +pub fn parse_second_amount(input: &str) -> IResult<&str, Amount> { + let (rest, (amount, _)) = pair( + parse_amount, + opt(alt(( + tag_no_case(" whole milk"), + tag_no_case(" milk"), + ))), + )(input)?; + Ok((rest, amount)) +} + +pub fn parse_left_amount(input: &str) -> IResult<&str, Amount> { + let (rest, (amount, _)) = pair( + preceded( + space1, + parse_amount, + ), + opt(alt(( + tag_no_case(" left"), + ))), + )(input)?; + Ok((rest, amount)) +} + +pub fn parse_and_plus(input: &str) -> IResult<&str, &str> { + alt(( + tag(" and "), + tag(" + "), + tag_no_case(" with "), + ))(input) +} + +pub fn parse_feed(input: &str) -> IResult<&str, Fed> { + let (rest, (main, plus, _timestamp, left)) = tuple(( + preceded( + tag("Fed "), + parse_main_amount, + ), + opt(preceded( + parse_and_plus, + parse_second_amount, + )), + parse_timestamp, + opt(parse_left_amount), + ))(input)?; + let fed = Fed { + formula: Some(main), + whole_milk: plus, + left: left, + }; + Ok((rest, fed)) +} + +fn main() -> Result<(), Box> { + let tests = vec![ + "Fed 180ml @ 11:05-11:25", + "Fed 200ml at 14:35-14:46", + "Fed 180ml formula @ 00:30-00:45", + "Fed 200ml premixed formula @ 14:58-15:15", + "Fed 120ml and 90ml whole milk at 03:40-03:50", + "Fed 150ml + 50ml whole milk @ 18:29-18:40", + "Fed 150ml with 50ml whole milk at 01:16-01:27 40ml left", + "Fed 90ml formula @ 10:00-10:10", + "Fed 120ml formula at 13:30-13:50", + "Fed 180ml formula @ 17:00-17:20", + "Fed 110ml and 90ml milk at 13:30-13:50", + ]; + for test in tests { + println!("Testing: {}", &test); + let result = parse_feed(test)?; + println!("Result: {:?}", &result); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_time_valid() { + let test_cases = vec![ + ("12:30", Time { h: 12, m: 30 }), + ("01:05", Time { h: 1, m: 5 }), + ("23:59", Time { h: 23, m: 59 }), + ("00:00", Time { h: 0, m: 0 }), + ("9:45", Time { h: 9, m: 45 }), + // Nb. Nothings stoping wonky times being parsed as valid.. + ("123:126", Time { h: 123, m: 126 }), + ]; + + for (input, expected) in test_cases { + let result = parse_time(input); + assert!(result.is_ok(), "Failed to parse '{}'", input); + let (rest, time) = result.unwrap(); + assert_eq!(time.h, expected.h, "Incorrect hours parsed for '{}'", input); + assert_eq!(time.m, expected.m, "Incorrect minutes parsed for '{}'", input); + assert!(rest.is_empty(), "Should consume entire input for '{}'", input); + } + } + + #[test] + fn test_parse_duration_valid() { + let test_cases = vec![ + ("12:30-12:45", Duration { start: Time { h: 12, m: 30 }, end: Time { h: 12, m: 45 } }), + ("10:30-15:05", Duration { start: Time { h: 10, m: 30 }, end: Time { h: 15, m: 05 } }), + ("2:30-2:05", Duration { start: Time { h: 2, m: 30 }, end: Time { h: 2, m: 5 } }), + ]; + + for (input, expected) in test_cases { + let result = parse_duration(input); + assert!(result.is_ok(), "Failed to parse duration '{}'", input); + let (rest, duration) = result.unwrap(); + assert_eq!(duration.start.h, expected.start.h, "Incorrect start hours parsed for '{}'", input); + assert_eq!(duration.start.m, expected.start.m, "Incorrect start minutes parsed for '{}'", input); + assert_eq!(duration.end.h, expected.end.h, "Incorrect end hours parsed for '{}'", input); + assert_eq!(duration.end.m, expected.end.m, "Incorrect end minutes parsed for '{}'", input); + assert!(rest.is_empty(), "Should have consumed entire input for '{}'", input); + } + } + + #[test] + fn test_parse_at_or_at() { + let test_cases = vec![ + ("@", "@"), + (" @ ", "@"), + ("at", "at"), + (" at ", "at"), + ]; + for (input, expected) in test_cases { + let result = parse_at_or_at(input); + assert!(result.is_ok(), "Somehow invalid? '{}'", input); + let (rest, at) = result.unwrap(); + assert_eq!(at, expected, "Should be the same type of @ or at for '{}'", input); + assert!(rest.is_empty(), "Should have consumed entire input for '{}'", input); + } + } + + #[test] + fn test_parse_feed() { + let test_cases = vec![ + ( + "Fed 150ml with 50ml whole milk at 01:16-01:27 40ml left", + "", + Fed { + formula: Some(Amount { volume: 150 }), + whole_milk: Some(Amount { volume: 50 }), + left: Some(Amount { volume: 40 }), + }, + ), + ]; + for (input, remaining, output) in test_cases { + let result = parse_feed(input); + assert!(result.is_ok(), "Parsing failed"); + let (rest, actual) = result.unwrap(); + assert_eq!(remaining, rest); + + if let Some(expected_formula) = output.formula { + assert!(actual.formula.is_some(), "Expected to have formula"); + assert_eq!(expected_formula.volume, actual.formula.unwrap().volume); + } else { + assert!(actual.formula.is_none(), "Wasn't expecting formula"); + } + + if let Some(expected_whole_milk) = output.whole_milk { + assert!(actual.whole_milk.is_some(), "Expected to have whole_milk"); + assert_eq!(expected_whole_milk.volume, actual.whole_milk.unwrap().volume); + } else { + assert!(actual.whole_milk.is_none(), "Wasn't expecting whole_milk"); + } + + if let Some(expected_left) = output.left { + assert!(actual.left.is_some(), "Expected to have left"); + assert_eq!(expected_left.volume, actual.left.unwrap().volume); + } else { + assert!(actual.left.is_none(), "Wasn't expecting left"); + } + } + } + + macro_rules! make_feed_test { + ( + $name:ident, + $input:expr, + $formula:expr, + $whole_milk:expr, + $left:expr $(,)? + ) => { + #[test] + fn $name() { + let result = parse_feed($input); + assert!(result.is_ok(), "Parsing failed"); + + let (rest, actual) = result.unwrap(); + assert_eq!("", rest); + + // formula + match ($formula, actual.formula) { + (Some(expected), Some(actual)) => { + assert_eq!(expected.volume, actual.volume, "formula mismatch"); + } + (None, None) => {} + (Some(_), None) => panic!("Expected formula but got none"), + (None, Some(_)) => panic!("Unexpected formula"), + } + + // whole_milk + match ($whole_milk, actual.whole_milk) { + (Some(expected), Some(actual)) => { + assert_eq!(expected.volume, actual.volume, "whole_milk mismatch"); + } + (None, None) => {} + (Some(_), None) => panic!("Expected whole_milk but got none"), + (None, Some(_)) => panic!("Unexpected whole_milk"), + } + + // left + match ($left, actual.left) { + (Some(expected), Some(actual)) => { + assert_eq!(expected.volume, actual.volume, "left mismatch"); + } + (None, None) => {} + (Some(_), None) => panic!("Expected left but got none"), + (None, Some(_)) => panic!("Unexpected left"), + } + } + }; + } + + make_feed_test!( + test_parse_feed_basic, + "Fed 150ml with 50ml whole milk at 01:16-01:27 40ml left", + Some(Amount { volume: 150 }), + Some(Amount { volume: 50 }), + Some(Amount { volume: 40 }), + ); + + make_feed_test!( + test_parse_feed_2, + "Fed 150ml + 50ml whole milk at 01:16-01:27 40ml left", + Some(Amount { volume: 150 }), + Some(Amount { volume: 50 }), + Some(Amount { volume: 40 }), + ); + + make_feed_test!( + test_parse_feed_3, + "Fed 150ml + 50ml milk at 01:16-01:27", + Some(Amount { volume: 150 }), + Some(Amount { volume: 50 }), + None::, + ); + + make_feed_test!( + test_parse_feed_5, + "Fed 150ml and 25ml at 01:16-01:27", + Some(Amount { volume: 150 }), + Some(Amount { volume: 25 }), + None::, + ); +} +