Initial attempt at a nom parser

This commit is contained in:
Alex Wright 2026-03-29 18:55:05 +01:00
commit b3d810484e
4 changed files with 438 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

32
Cargo.lock generated Normal file
View File

@ -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",
]

7
Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[package]
name = "parse"
version = "0.1.0"
edition = "2024"
[dependencies]
nom = "7.*"

398
src/main.rs Normal file
View File

@ -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<Amount>,
pub whole_milk: Option<Amount>,
pub left: Option<Amount>,
}
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<dyn std::error::Error>> {
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::<Amount>,
);
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::<Amount>,
);
}