Skip to content

Commit 9c4711a

Browse files
committed
fix: servings
1 parent c588381 commit 9c4711a

8 files changed

Lines changed: 308 additions & 143 deletions

File tree

fuzz/fuzz_targets/fuzz_parser.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
use libfuzzer_sys::fuzz_target;
44

5-
use cooklang::{CooklangParser, Extensions, Converter};
5+
use cooklang::{Converter, CooklangParser, Extensions};
66

77
fuzz_target!(|contents: &str| {
88
let parser = CooklangParser::new(Extensions::all(), Converter::default());

src/aisle.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,8 @@ pub fn parse(input: &str) -> Result<AisleConf, AisleConfError> {
275275
pub fn parse_lenient(input: &str) -> PassResult<AisleConf> {
276276
let mut report = SourceReport::empty();
277277

278-
let conf = parse_core(input, true, Some(&mut report)).expect("lenient parsing should never fail");
278+
let conf =
279+
parse_core(input, true, Some(&mut report)).expect("lenient parsing should never fail");
279280
PassResult::new(Some(conf), report)
280281
}
281282

src/metadata.rs

Lines changed: 66 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,8 @@ impl Metadata {
200200
}
201201

202202
/// Servings the recipe is made for
203-
///
204-
/// This returns a list of servings to support scaling. See
205-
/// [`CooklangValueExt::as_servings`] for the expected format.
206-
pub fn servings(&self) -> Option<Vec<u32>> {
207-
self.get(StdKey::Servings)
208-
.and_then(CooklangValueExt::as_servings)
203+
pub fn servings(&self) -> Option<Servings> {
204+
self.get(StdKey::Servings).and_then(|v| v.as_servings())
209205
}
210206

211207
/// Recipe locale
@@ -271,35 +267,6 @@ pub trait CooklangValueExt: private::Sealed {
271267
/// Duplicates and empty entries removed.
272268
fn as_tags(&self) -> Option<Vec<Cow<str>>>;
273269

274-
/// Pipe ('|') separated string or YAML sequence of numbers
275-
///
276-
/// This extracts only the number at the beginning, but the entry can have
277-
/// extra text, like the unit. For example:
278-
/// ```yaml
279-
/// servings: 5 cups worth
280-
/// ```
281-
/// will return `vec![5]`.
282-
///
283-
/// These values will be used for scaling the recipe. If you want to display
284-
/// the full text alongside the values, you can do something like:
285-
///
286-
/// ```no_run
287-
/// # use cooklang::metadata::*;
288-
/// # fn f() -> Option<()> {
289-
/// # let metadata = Metadata::default();
290-
/// let nums = metadata.get("servings")?.as_servings()?;
291-
/// let texts = metadata.get("servings")?.as_string_list("|")?;
292-
///
293-
/// for (num, text) in nums.iter().zip(texts.iter()) {
294-
/// println!("{num} - '{text}'")
295-
/// }
296-
/// # Some(())
297-
/// # }
298-
/// ```
299-
///
300-
/// Duplicates not allowed, will return `None`.
301-
fn as_servings(&self) -> Option<Vec<u32>>;
302-
303270
/// String separated by `sep` or YAML sequence of strings and/or numbers
304271
///
305272
/// This only checks types and convert numbers to strings if neccesary.
@@ -345,17 +312,18 @@ pub trait CooklangValueExt: private::Sealed {
345312

346313
/// String or number as a string
347314
fn as_str_like(&self) -> Option<Cow<str>>;
315+
316+
/// Get servings information
317+
///
318+
/// Can be a number or a string that parses to [`Servings`]
319+
fn as_servings(&self) -> Option<Servings>;
348320
}
349321

350322
impl CooklangValueExt for serde_yaml::Value {
351323
fn as_tags(&self) -> Option<Vec<Cow<str>>> {
352324
value_as_tags(self).ok()
353325
}
354326

355-
fn as_servings(&self) -> Option<Vec<u32>> {
356-
value_as_servings(self).ok()
357-
}
358-
359327
fn as_string_list<'a>(&'a self, sep: &str) -> Option<Vec<Cow<'a, str>>> {
360328
if let Some(s) = self.as_str() {
361329
let v = s.split(sep).map(|e| e.into()).collect();
@@ -415,6 +383,25 @@ impl CooklangValueExt for serde_yaml::Value {
415383
None
416384
}
417385
}
386+
387+
fn as_servings(&self) -> Option<Servings> {
388+
// Try as number first
389+
if let Some(n) = self.as_u32() {
390+
return Some(Servings::Number(n));
391+
}
392+
393+
// Return as text if it's a string
394+
if let Some(s) = self.as_str() {
395+
// Try to parse as number
396+
if let Ok(n) = s.parse::<u32>() {
397+
Some(Servings::Number(n))
398+
} else {
399+
Some(Servings::Text(s.to_string()))
400+
}
401+
} else {
402+
None
403+
}
404+
}
418405
}
419406

420407
fn value_as_tags(val: &serde_yaml::Value) -> Result<Vec<Cow<str>>, MetadataError> {
@@ -441,54 +428,6 @@ fn value_as_tags(val: &serde_yaml::Value) -> Result<Vec<Cow<str>>, MetadataError
441428
Ok(tags)
442429
}
443430

444-
fn value_as_servings(val: &serde_yaml::Value) -> Result<Vec<u32>, MetadataError> {
445-
fn extract_value(s: &str) -> Result<u32, std::num::ParseIntError> {
446-
let idx = s
447-
.find(|c: char| !c.is_ascii_alphanumeric())
448-
.unwrap_or(s.len());
449-
let n_str = &s[..idx];
450-
n_str.parse()
451-
}
452-
453-
let servings: Vec<u32> = if let Some(n) = val.as_u32() {
454-
vec![n]
455-
} else if let Some(s) = val.as_str() {
456-
s.split('|')
457-
.map(str::trim)
458-
.map(extract_value)
459-
.collect::<Result<Vec<_>, _>>()?
460-
} else if let Some(seq) = val.as_sequence() {
461-
let mut v = Vec::with_capacity(seq.len());
462-
for e in seq {
463-
if let Some(n) = e.as_u32() {
464-
v.push(n);
465-
} else if let Some(s) = e.as_str() {
466-
v.push(extract_value(s)?);
467-
} else {
468-
return Err(MetadataError::BadSequenceType {
469-
expected: MetaType::Number,
470-
got: MetaType::from(e),
471-
});
472-
}
473-
}
474-
v
475-
} else {
476-
return Err(MetadataError::expect_type(MetaType::Sequence, val));
477-
};
478-
479-
let l = servings.len();
480-
let dedup_l = {
481-
let mut temp = servings.clone();
482-
temp.sort_unstable();
483-
temp.dedup();
484-
temp.len()
485-
};
486-
if l != dedup_l {
487-
return Err(MetadataError::DuplicateServings { servings });
488-
}
489-
Ok(servings)
490-
}
491-
492431
fn value_as_minutes(val: &serde_yaml::Value, converter: &Converter) -> Result<u32, MetadataError> {
493432
if let Some(s) = val.as_str() {
494433
let t = parse_time(s, converter)?;
@@ -554,7 +493,9 @@ pub(crate) fn check_std_entry(
554493
) -> Result<(), MetadataError> {
555494
match key {
556495
StdKey::Servings => {
557-
value_as_servings(value)?;
496+
value
497+
.as_u32()
498+
.ok_or(MetadataError::expect_type(MetaType::Number, value))?;
558499
}
559500
StdKey::Tags => {
560501
value_as_tags(value)?;
@@ -589,6 +530,43 @@ pub(crate) fn check_std_entry(
589530
Ok(())
590531
}
591532

533+
/// Servings information that can be numeric or a string
534+
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
535+
#[serde(untagged)]
536+
pub enum Servings {
537+
/// Numeric servings count
538+
Number(u32),
539+
/// String servings (when it can't be parsed as a number)
540+
Text(String),
541+
}
542+
543+
impl Servings {
544+
/// Get the numeric value if available
545+
pub fn as_number(&self) -> Option<u32> {
546+
match self {
547+
Servings::Number(n) => Some(*n),
548+
Servings::Text(_) => None,
549+
}
550+
}
551+
552+
/// Get as text
553+
pub fn as_text(&self) -> Option<&str> {
554+
match self {
555+
Servings::Number(_) => None,
556+
Servings::Text(s) => Some(s),
557+
}
558+
}
559+
}
560+
561+
impl std::fmt::Display for Servings {
562+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563+
match self {
564+
Servings::Number(n) => write!(f, "{}", n),
565+
Servings::Text(s) => write!(f, "{}", s),
566+
}
567+
}
568+
}
569+
592570
/// Combination of name and URL.
593571
///
594572
/// At least one of the fields is [`Some`].
@@ -845,8 +823,6 @@ pub(crate) enum MetadataError {
845823
BadMapping,
846824
#[error(transparent)]
847825
ParseIntError(#[from] std::num::ParseIntError),
848-
#[error("Duplicate servings: {servings:?}")]
849-
DuplicateServings { servings: Vec<u32> },
850826
#[error(transparent)]
851827
ParseTimeError(#[from] ParseTimeError),
852828
#[error("Invalid locale: {0}")]

src/parser/quantity.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,7 @@ fn parse_regular_quantity<'i>(bp: &mut BlockParser<'_, 'i>) -> ParsedQuantity<'i
8787
}
8888

8989
fn parse_advanced_quantity<'i>(bp: &mut BlockParser<'_, 'i>) -> Option<ParsedQuantity<'i>> {
90-
if bp
91-
.tokens()
92-
.iter()
93-
.any(|t| matches!(t.kind, T![%]))
94-
{
90+
if bp.tokens().iter().any(|t| matches!(t.kind, T![%])) {
9591
return None;
9692
}
9793

@@ -138,7 +134,10 @@ fn parse_advanced_quantity<'i>(bp: &mut BlockParser<'_, 'i>) -> Option<ParsedQua
138134
Some(ParsedQuantity {
139135
quantity: Located::new(
140136
Quantity {
141-
value: QuantityValue {value, scaling_lock},
137+
value: QuantityValue {
138+
value,
139+
scaling_lock,
140+
},
142141
unit: Some(unit),
143142
},
144143
tokens_span(bp.tokens()),
@@ -152,7 +151,10 @@ fn value(bp: &mut BlockParser) -> QuantityValue {
152151
let value_tokens = bp.consume_while(|t| !matches!(t, T![%]));
153152
let value = parse_value(value_tokens, bp);
154153

155-
QuantityValue { value, scaling_lock }
154+
QuantityValue {
155+
value,
156+
scaling_lock,
157+
}
156158
}
157159

158160
fn scaling_lock(bp: &mut BlockParser) -> Option<Span> {
@@ -162,8 +164,8 @@ fn scaling_lock(bp: &mut BlockParser) -> Option<Span> {
162164
T![=] => {
163165
let tok = bp.bump_any();
164166
Some(tok.span)
165-
},
166-
_ => None
167+
}
168+
_ => None,
167169
}
168170
}
169171

@@ -329,7 +331,7 @@ fn float(tokens: &[Token], bp: &BlockParser) -> Result<f64, SourceDiag> {
329331
#[cfg(test)]
330332
mod tests {
331333
use super::*;
332-
use crate::{parser::TokenStream};
334+
use crate::parser::TokenStream;
333335
use test_case::test_case;
334336

335337
macro_rules! t {

0 commit comments

Comments
 (0)