From abe0b6d214c93ca83c7b3eae9ff372bc36be6f62 Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Sun, 18 Jan 2026 16:24:53 -0800 Subject: [PATCH 01/10] Initial draft --- .../concept/family-recipes/.meta/exemplar.fs | 73 +++++++++++++++++++ .../concept/family-recipes/FamilyRecipes.fs | 15 ++++ .../family-recipes/FamilyRecipes.fsproj | 21 ++++++ .../family-recipes/FamilyRecipesTests.fs | 58 +++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 exercises/concept/family-recipes/.meta/exemplar.fs create mode 100644 exercises/concept/family-recipes/FamilyRecipes.fs create mode 100644 exercises/concept/family-recipes/FamilyRecipes.fsproj create mode 100644 exercises/concept/family-recipes/FamilyRecipesTests.fs diff --git a/exercises/concept/family-recipes/.meta/exemplar.fs b/exercises/concept/family-recipes/.meta/exemplar.fs new file mode 100644 index 000000000..a39de5b2e --- /dev/null +++ b/exercises/concept/family-recipes/.meta/exemplar.fs @@ -0,0 +1,73 @@ +module FamilyRecipes + +type ParseError = +| MissingTitle +| MissingIngredients +| MissingInstructions + +type Recipe = { + Title: string + Ingredients: string + Instructions: string +} + +type ParseState = +| ReadingTitle +| SeekingIngredientsHeading +| ReadingIngredientsList +| SeekingInstructionsHeading +| ReadingInstructions + +let rec parseLines (lines: string array, state: ParseState, recipe: Recipe): Result = + match state with + | ReadingTitle -> parseTitle lines recipe + | SeekingIngredientsHeading -> parseIngredientsHeading lines recipe + | ReadingIngredientsList -> parseIngredientsList lines recipe + | SeekingInstructionsHeading -> parseInstructionsHeading lines recipe + | ReadingInstructions -> parseReadingInstructions lines recipe + +and parseTitle lines recipe = + if lines.Length = 0 || lines[0].Length = 0 then + Error MissingTitle + else + parseLines(lines[1..], SeekingIngredientsHeading, {recipe with Title = lines[0]}) + +and parseIngredientsHeading lines recipe = + if lines.Length = 0 then + Error MissingIngredients + else + let nextState = if lines[0] = "Ingredients:" then ReadingIngredientsList else SeekingIngredientsHeading + parseLines(lines[1..], nextState, recipe) + +and parseIngredientsList lines recipe = + if lines.Length = 0 then + if recipe.Ingredients.Length = 0 then + Error MissingIngredients + else + Error MissingInstructions + elif lines[0].Length = 0 then + if recipe.Ingredients.Length = 0 then + Error MissingIngredients + else + parseLines(lines[1..], SeekingInstructionsHeading, recipe) + else + parseLines(lines[1..], ReadingIngredientsList, {recipe with Ingredients = lines[0]}) + +and parseInstructionsHeading lines recipe = + if lines.Length = 0 then + Error MissingInstructions + else + let nextState = if lines[0] = "Instructions:" then ReadingInstructions else SeekingInstructionsHeading + parseLines(lines[1..], nextState, recipe) + +and parseReadingInstructions lines recipe = + if lines.Length = 0 then + if recipe.Instructions.Length = 0 then + Error MissingInstructions + else + Ok(recipe) + else + parseLines(lines[1..], ReadingInstructions, {recipe with Instructions = recipe.Instructions + lines[0]}) + +let parse (input: string) : Result = + parseLines(input.Split('\n'), ReadingTitle, {Title = ""; Ingredients = ""; Instructions = ""}) diff --git a/exercises/concept/family-recipes/FamilyRecipes.fs b/exercises/concept/family-recipes/FamilyRecipes.fs new file mode 100644 index 000000000..03830558d --- /dev/null +++ b/exercises/concept/family-recipes/FamilyRecipes.fs @@ -0,0 +1,15 @@ +module FamilyRecipes + +type ParseError = +| MissingTitle +| MissingIngredients +| MissingInstructions + +type Recipe = { + Title: string + Ingredients: string + Instructions: string +} + +let parse input = + failwith "Please implement this function" diff --git a/exercises/concept/family-recipes/FamilyRecipes.fsproj b/exercises/concept/family-recipes/FamilyRecipes.fsproj new file mode 100644 index 000000000..2da4fee5b --- /dev/null +++ b/exercises/concept/family-recipes/FamilyRecipes.fsproj @@ -0,0 +1,21 @@ + + + + net9.0 + + false + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/exercises/concept/family-recipes/FamilyRecipesTests.fs b/exercises/concept/family-recipes/FamilyRecipesTests.fs new file mode 100644 index 000000000..af5e4b78c --- /dev/null +++ b/exercises/concept/family-recipes/FamilyRecipesTests.fs @@ -0,0 +1,58 @@ +module Tests + +open FsUnit.Xunit +open Xunit + +open FamilyRecipes + +[] +let ``Error on blank recipe`` () = + let expected: Result = Error MissingTitle + parse "" |> should equal expected + +[] +let ``Error on title without ingredients or instructions`` () = + let expected: Result = Error MissingIngredients + parse "foo" |> should equal expected + +[] +let ``Error on title and ingredients heading without ingredients list`` () = + let expected: Result = Error MissingIngredients + parse "A Title\n\nIngredients:" |> should equal expected + +[] +let ``Error on title and ingredients without instructions`` () = + let expected: Result = Error MissingInstructions + parse "A Title\n\nIngredients:\nsome ingredient" |> should equal expected + +[] +let ``Error on all required sections but with empty ingredients list`` () = + let expected: Result = Error MissingIngredients + parse "A Title\n\nIngredients:\n\nInstructions:\nSome instructions" |> should equal expected + +[] +let ``Error on all required sections but missing instructions`` () = + let expected: Result = Error MissingInstructions + parse "A Title\n\nIngredients:\nSome ingredient\n\nInstructions:" |> should equal expected + +[] +let ``Success on minimal recipe`` () = + let expected: Result = Ok { + Title = "Glass of Wine" + Ingredients = "1 cup wine" + Instructions = "Pour wine into wine glass." + } + let input = """Glass of Wine + +Ingredients: +1 cup wine + +Instructions: +Pour wine into wine glass. +""" + parse input |> should equal expected + +// TODO: Make Ingredients a list of Ingredient records, with quantity, units, and substance +// TODO: Add Ingredients tests for missing/bad quantities, missing/bad units, missing substance +// TODO: Test the ability to parse fractional quantities +// TODO: Organize into tasks From b48a70b31db984351416305db6e66c34105d58cd Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Mon, 19 Jan 2026 19:00:20 -0800 Subject: [PATCH 02/10] Add ingredient parsing with integer quantities --- .../concept/family-recipes/.meta/exemplar.fs | 46 +++++++++++++-- .../concept/family-recipes/FamilyRecipes.fs | 9 ++- .../family-recipes/FamilyRecipesTests.fs | 57 ++++++++++++++++--- 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/exercises/concept/family-recipes/.meta/exemplar.fs b/exercises/concept/family-recipes/.meta/exemplar.fs index a39de5b2e..bc98d22dd 100644 --- a/exercises/concept/family-recipes/.meta/exemplar.fs +++ b/exercises/concept/family-recipes/.meta/exemplar.fs @@ -4,10 +4,17 @@ type ParseError = | MissingTitle | MissingIngredients | MissingInstructions +| InvalidIngredientQuantity +| MissingIngredientItem + +type Ingredient = { + Quantity: int + Item: string +} type Recipe = { Title: string - Ingredients: string + Ingredients: Ingredient list Instructions: string } @@ -18,6 +25,31 @@ type ParseState = | SeekingInstructionsHeading | ReadingInstructions + +let removeTrailingNewline (text: string): string = + if text[text.Length - 1] = '\n' then + text[0..text.Length - 2] + else + text + +let splitOnce (text: string) (separator: char): (string * string) option = + match text.IndexOf(separator) with + | -1 -> None + | index -> Some((text.Substring(0, index), text.Substring(index + 1))) + +let parseIngredient (text: string): Result = + match splitOnce text ' ' with + | Some(head, tail) -> + let success, quantity = System.Int32.TryParse head + if success then + Ok { + Quantity = quantity + Item = tail + } + else + Error InvalidIngredientQuantity + | _ -> Error MissingIngredientItem + let rec parseLines (lines: string array, state: ParseState, recipe: Recipe): Result = match state with | ReadingTitle -> parseTitle lines recipe @@ -51,7 +83,11 @@ and parseIngredientsList lines recipe = else parseLines(lines[1..], SeekingInstructionsHeading, recipe) else - parseLines(lines[1..], ReadingIngredientsList, {recipe with Ingredients = lines[0]}) + match parseIngredient lines[0] with + | Ok ingredient -> parseLines(lines[1..], ReadingIngredientsList, { + recipe with Ingredients = recipe.Ingredients @ [ingredient] + }) + | Error e -> Error e and parseInstructionsHeading lines recipe = if lines.Length = 0 then @@ -65,9 +101,9 @@ and parseReadingInstructions lines recipe = if recipe.Instructions.Length = 0 then Error MissingInstructions else - Ok(recipe) + Ok({ recipe with Instructions = recipe.Instructions |> removeTrailingNewline }) else - parseLines(lines[1..], ReadingInstructions, {recipe with Instructions = recipe.Instructions + lines[0]}) + parseLines(lines[1..], ReadingInstructions, {recipe with Instructions = recipe.Instructions + lines[0] + "\n"}) let parse (input: string) : Result = - parseLines(input.Split('\n'), ReadingTitle, {Title = ""; Ingredients = ""; Instructions = ""}) + parseLines(input.Split('\n'), ReadingTitle, {Title = ""; Ingredients = []; Instructions = ""}) diff --git a/exercises/concept/family-recipes/FamilyRecipes.fs b/exercises/concept/family-recipes/FamilyRecipes.fs index 03830558d..f94cd701d 100644 --- a/exercises/concept/family-recipes/FamilyRecipes.fs +++ b/exercises/concept/family-recipes/FamilyRecipes.fs @@ -4,10 +4,17 @@ type ParseError = | MissingTitle | MissingIngredients | MissingInstructions +| InvalidIngredientQuantity +| MissingIngredientItem + +type Ingredient = { + Quantity: int + Item: string +} type Recipe = { Title: string - Ingredients: string + Ingredients: Ingredient list Instructions: string } diff --git a/exercises/concept/family-recipes/FamilyRecipesTests.fs b/exercises/concept/family-recipes/FamilyRecipesTests.fs index af5e4b78c..d74135a4d 100644 --- a/exercises/concept/family-recipes/FamilyRecipesTests.fs +++ b/exercises/concept/family-recipes/FamilyRecipesTests.fs @@ -23,7 +23,7 @@ let ``Error on title and ingredients heading without ingredients list`` () = [] let ``Error on title and ingredients without instructions`` () = let expected: Result = Error MissingInstructions - parse "A Title\n\nIngredients:\nsome ingredient" |> should equal expected + parse "A Title\n\nIngredients:\n1 ingredient" |> should equal expected [] let ``Error on all required sections but with empty ingredients list`` () = @@ -33,26 +33,65 @@ let ``Error on all required sections but with empty ingredients list`` () = [] let ``Error on all required sections but missing instructions`` () = let expected: Result = Error MissingInstructions - parse "A Title\n\nIngredients:\nSome ingredient\n\nInstructions:" |> should equal expected + parse "A Title\n\nIngredients:\n1 ingredient\n\nInstructions:" |> should equal expected [] -let ``Success on minimal recipe`` () = +let ``Error on non-numeric ingredient quantity`` () = + let expected: Result = Error InvalidIngredientQuantity + parse "A Title\n\nIngredients:\nfoo bar\n\nInstructions:\nbuzz" |> should equal expected + +[] +let ``Error on missing ingredient item`` () = + let expected: Result = Error MissingIngredientItem + parse "A Title\n\nIngredients:\n24\n\nInstructions:\nbuzz" |> should equal expected + + +[] +let ``Minimal valid recipe`` () = let expected: Result = Ok { Title = "Glass of Wine" - Ingredients = "1 cup wine" - Instructions = "Pour wine into wine glass." + Ingredients = [ + { Quantity = 1; Item = "cup of wine" } + ] + Instructions = "Pour wine into wine glass.\n" } let input = """Glass of Wine Ingredients: -1 cup wine +1 cup of wine Instructions: Pour wine into wine glass. """ parse input |> should equal expected -// TODO: Make Ingredients a list of Ingredient records, with quantity, units, and substance -// TODO: Add Ingredients tests for missing/bad quantities, missing/bad units, missing substance -// TODO: Test the ability to parse fractional quantities +[] +let ``Valid recipe with multiple ingredients, integer quantities and varying units`` () = + let expected: Result = Ok { + Title = "Gin and Tonic" + Ingredients = [ + { Quantity = 1; Item = "cup tonic water" }; + { Quantity = 2; Item = "shots of gin" }; + { Quantity = 5; Item = "cubes of ice" }; + ] + Instructions = """Put ice cubes into a glass. +Stir tonic water and gin in another glass. +Pour tonic water and gin mixture into glass with ice. +""" + } + let input = """Gin and Tonic + +Ingredients: +1 cup tonic water +2 shots of gin +5 cubes of ice + +Instructions: +Put ice cubes into a glass. +Stir tonic water and gin in another glass. +Pour tonic water and gin mixture into glass with ice. +""" + parse input |> should equal expected + +// TODO: (maybe) Test the ability to parse fractional quantities // TODO: Organize into tasks From c2ea535a8d8746fc48634de83ca1eb0507f58041 Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Tue, 20 Jan 2026 10:34:46 -0800 Subject: [PATCH 03/10] Clean up function signatures --- .../concept/family-recipes/.meta/exemplar.fs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/exercises/concept/family-recipes/.meta/exemplar.fs b/exercises/concept/family-recipes/.meta/exemplar.fs index bc98d22dd..abbd97ebc 100644 --- a/exercises/concept/family-recipes/.meta/exemplar.fs +++ b/exercises/concept/family-recipes/.meta/exemplar.fs @@ -50,7 +50,7 @@ let parseIngredient (text: string): Result = Error InvalidIngredientQuantity | _ -> Error MissingIngredientItem -let rec parseLines (lines: string array, state: ParseState, recipe: Recipe): Result = +let rec parseLines (lines: string array) (state: ParseState) (recipe: Recipe): Result = match state with | ReadingTitle -> parseTitle lines recipe | SeekingIngredientsHeading -> parseIngredientsHeading lines recipe @@ -62,14 +62,14 @@ and parseTitle lines recipe = if lines.Length = 0 || lines[0].Length = 0 then Error MissingTitle else - parseLines(lines[1..], SeekingIngredientsHeading, {recipe with Title = lines[0]}) + parseLines lines[1..] SeekingIngredientsHeading { recipe with Title = lines[0] } and parseIngredientsHeading lines recipe = if lines.Length = 0 then Error MissingIngredients else let nextState = if lines[0] = "Ingredients:" then ReadingIngredientsList else SeekingIngredientsHeading - parseLines(lines[1..], nextState, recipe) + parseLines lines[1..] nextState recipe and parseIngredientsList lines recipe = if lines.Length = 0 then @@ -81,12 +81,12 @@ and parseIngredientsList lines recipe = if recipe.Ingredients.Length = 0 then Error MissingIngredients else - parseLines(lines[1..], SeekingInstructionsHeading, recipe) + parseLines lines[1..] SeekingInstructionsHeading recipe else match parseIngredient lines[0] with - | Ok ingredient -> parseLines(lines[1..], ReadingIngredientsList, { + | Ok ingredient -> parseLines lines[1..] ReadingIngredientsList { recipe with Ingredients = recipe.Ingredients @ [ingredient] - }) + } | Error e -> Error e and parseInstructionsHeading lines recipe = @@ -94,16 +94,16 @@ and parseInstructionsHeading lines recipe = Error MissingInstructions else let nextState = if lines[0] = "Instructions:" then ReadingInstructions else SeekingInstructionsHeading - parseLines(lines[1..], nextState, recipe) + parseLines lines[1..] nextState recipe and parseReadingInstructions lines recipe = if lines.Length = 0 then if recipe.Instructions.Length = 0 then Error MissingInstructions else - Ok({ recipe with Instructions = recipe.Instructions |> removeTrailingNewline }) + Ok { recipe with Instructions = recipe.Instructions |> removeTrailingNewline } else - parseLines(lines[1..], ReadingInstructions, {recipe with Instructions = recipe.Instructions + lines[0] + "\n"}) + parseLines lines[1..] ReadingInstructions { recipe with Instructions = recipe.Instructions + lines[0] + "\n" } let parse (input: string) : Result = - parseLines(input.Split('\n'), ReadingTitle, {Title = ""; Ingredients = []; Instructions = ""}) + parseLines (input.Split('\n')) ReadingTitle { Title = ""; Ingredients = []; Instructions = "" } From 3c08d02bb95c1fd7d3685d58e1fba5350f2566a6 Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Tue, 20 Jan 2026 14:37:02 -0800 Subject: [PATCH 04/10] Simplify & clean up --- .../concept/family-recipes/.meta/exemplar.fs | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/exercises/concept/family-recipes/.meta/exemplar.fs b/exercises/concept/family-recipes/.meta/exemplar.fs index abbd97ebc..f6299971f 100644 --- a/exercises/concept/family-recipes/.meta/exemplar.fs +++ b/exercises/concept/family-recipes/.meta/exemplar.fs @@ -26,20 +26,14 @@ type ParseState = | ReadingInstructions -let removeTrailingNewline (text: string): string = - if text[text.Length - 1] = '\n' then - text[0..text.Length - 2] - else - text - let splitOnce (text: string) (separator: char): (string * string) option = match text.IndexOf(separator) with | -1 -> None - | index -> Some((text.Substring(0, index), text.Substring(index + 1))) + | index -> Some (text.Substring(0, index), text.Substring(index + 1)) let parseIngredient (text: string): Result = match splitOnce text ' ' with - | Some(head, tail) -> + | Some (head, tail) -> let success, quantity = System.Int32.TryParse head if success then Ok { @@ -72,16 +66,12 @@ and parseIngredientsHeading lines recipe = parseLines lines[1..] nextState recipe and parseIngredientsList lines recipe = - if lines.Length = 0 then - if recipe.Ingredients.Length = 0 then - Error MissingIngredients - else - Error MissingInstructions + if recipe.Ingredients.Length = 0 && (lines.Length = 0 || lines[0].Length = 0) then + Error MissingIngredients + elif lines.Length = 0 then + Error MissingInstructions elif lines[0].Length = 0 then - if recipe.Ingredients.Length = 0 then - Error MissingIngredients - else - parseLines lines[1..] SeekingInstructionsHeading recipe + parseLines lines[1..] SeekingInstructionsHeading recipe else match parseIngredient lines[0] with | Ok ingredient -> parseLines lines[1..] ReadingIngredientsList { @@ -101,9 +91,12 @@ and parseReadingInstructions lines recipe = if recipe.Instructions.Length = 0 then Error MissingInstructions else - Ok { recipe with Instructions = recipe.Instructions |> removeTrailingNewline } + Ok { recipe with Instructions = recipe.Instructions } else - parseLines lines[1..] ReadingInstructions { recipe with Instructions = recipe.Instructions + lines[0] + "\n" } + let separator = if recipe.Instructions.Length = 0 then "" else "\n" + parseLines lines[1..] ReadingInstructions { + recipe with Instructions = recipe.Instructions + separator + lines[0] + } let parse (input: string) : Result = parseLines (input.Split('\n')) ReadingTitle { Title = ""; Ingredients = []; Instructions = "" } From 5e9a104ffbd57438a9ea1f2b3cdf16bfb38f090e Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Wed, 21 Jan 2026 11:25:54 -0800 Subject: [PATCH 05/10] Non-recursive version using fold --- .../concept/family-recipes/.meta/exemplar.fs | 92 ++++++++++--------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/exercises/concept/family-recipes/.meta/exemplar.fs b/exercises/concept/family-recipes/.meta/exemplar.fs index f6299971f..3ee728664 100644 --- a/exercises/concept/family-recipes/.meta/exemplar.fs +++ b/exercises/concept/family-recipes/.meta/exemplar.fs @@ -44,59 +44,63 @@ let parseIngredient (text: string): Result = Error InvalidIngredientQuantity | _ -> Error MissingIngredientItem -let rec parseLines (lines: string array) (state: ParseState) (recipe: Recipe): Result = - match state with - | ReadingTitle -> parseTitle lines recipe - | SeekingIngredientsHeading -> parseIngredientsHeading lines recipe - | ReadingIngredientsList -> parseIngredientsList lines recipe - | SeekingInstructionsHeading -> parseInstructionsHeading lines recipe - | ReadingInstructions -> parseReadingInstructions lines recipe - -and parseTitle lines recipe = - if lines.Length = 0 || lines[0].Length = 0 then +let parseTitle (line: string) (recipe: Recipe) : Result = + if line.Length = 0 then Error MissingTitle else - parseLines lines[1..] SeekingIngredientsHeading { recipe with Title = lines[0] } + Ok (SeekingIngredientsHeading, { recipe with Title = line }) -and parseIngredientsHeading lines recipe = - if lines.Length = 0 then - Error MissingIngredients - else - let nextState = if lines[0] = "Ingredients:" then ReadingIngredientsList else SeekingIngredientsHeading - parseLines lines[1..] nextState recipe +let parseIngredientsHeading (line: string) (recipe: Recipe) : Result = + let nextState = if line = "Ingredients:" then ReadingIngredientsList else SeekingIngredientsHeading + Ok (nextState, recipe) -and parseIngredientsList lines recipe = - if recipe.Ingredients.Length = 0 && (lines.Length = 0 || lines[0].Length = 0) then +let parseIngredientsList (line: string) (recipe: Recipe) : Result = + if recipe.Ingredients.Length = 0 && line.Length = 0 then Error MissingIngredients - elif lines.Length = 0 then - Error MissingInstructions - elif lines[0].Length = 0 then - parseLines lines[1..] SeekingInstructionsHeading recipe + elif line.Length = 0 then + Ok (SeekingInstructionsHeading, recipe) else - match parseIngredient lines[0] with - | Ok ingredient -> parseLines lines[1..] ReadingIngredientsList { + match parseIngredient line with + | Ok ingredient -> Ok (ReadingIngredientsList, { recipe with Ingredients = recipe.Ingredients @ [ingredient] - } + }) | Error e -> Error e -and parseInstructionsHeading lines recipe = - if lines.Length = 0 then - Error MissingInstructions - else - let nextState = if lines[0] = "Instructions:" then ReadingInstructions else SeekingInstructionsHeading - parseLines lines[1..] nextState recipe +let parseInstructionsHeading (line: string) (recipe: Recipe) : Result = + let nextState = if line = "Instructions:" then ReadingInstructions else SeekingInstructionsHeading + Ok (nextState, recipe) -and parseReadingInstructions lines recipe = - if lines.Length = 0 then - if recipe.Instructions.Length = 0 then - Error MissingInstructions - else - Ok { recipe with Instructions = recipe.Instructions } - else - let separator = if recipe.Instructions.Length = 0 then "" else "\n" - parseLines lines[1..] ReadingInstructions { - recipe with Instructions = recipe.Instructions + separator + lines[0] - } +let parseReadingInstructions (line: string) (recipe: Recipe) : Result = + let separator = if recipe.Instructions.Length = 0 then "" else "\n" + Ok (ReadingInstructions, { + recipe with Instructions = recipe.Instructions + separator + line + }) + +let parseLine (stateRecipe: Result) (line: string) : Result = + match stateRecipe with + | Ok (state, recipe) -> + let lineParser = + match state with + | ReadingTitle -> parseTitle + | SeekingIngredientsHeading -> parseIngredientsHeading + | ReadingIngredientsList -> parseIngredientsList + | SeekingInstructionsHeading -> parseInstructionsHeading + | ReadingInstructions -> parseReadingInstructions + lineParser line recipe + | Error e -> Error e + +let parseLines (lines: string array): Result = + let initialState: ParseState = ReadingTitle + let initialRecipe: Recipe = { Title = ""; Ingredients = []; Instructions = "" } + let result = lines |> Array.fold parseLine (Ok (initialState, initialRecipe)) + match result with + | Ok (_, recipe) -> Ok recipe + | Error e -> Error e let parse (input: string) : Result = - parseLines (input.Split('\n')) ReadingTitle { Title = ""; Ingredients = []; Instructions = "" } + match parseLines (input.Split('\n')) with + | Ok recipe -> + if recipe.Ingredients.Length = 0 then Error MissingIngredients + elif recipe.Instructions.Length = 0 then Error MissingInstructions + else Ok recipe + | Error e -> Error e From 98dabd989c0b34ee5a78a800661170a3c7851609 Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Thu, 22 Jan 2026 15:26:20 -0800 Subject: [PATCH 06/10] Refactor for clarity & brevity --- .../concept/family-recipes/.meta/exemplar.fs | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/exercises/concept/family-recipes/.meta/exemplar.fs b/exercises/concept/family-recipes/.meta/exemplar.fs index 3ee728664..8c72a9f40 100644 --- a/exercises/concept/family-recipes/.meta/exemplar.fs +++ b/exercises/concept/family-recipes/.meta/exemplar.fs @@ -36,19 +36,13 @@ let parseIngredient (text: string): Result = | Some (head, tail) -> let success, quantity = System.Int32.TryParse head if success then - Ok { - Quantity = quantity - Item = tail - } + Ok { Quantity = quantity; Item = tail } else Error InvalidIngredientQuantity | _ -> Error MissingIngredientItem let parseTitle (line: string) (recipe: Recipe) : Result = - if line.Length = 0 then - Error MissingTitle - else - Ok (SeekingIngredientsHeading, { recipe with Title = line }) + Ok (SeekingIngredientsHeading, { recipe with Title = line }) let parseIngredientsHeading (line: string) (recipe: Recipe) : Result = let nextState = if line = "Ingredients:" then ReadingIngredientsList else SeekingIngredientsHeading @@ -70,14 +64,14 @@ let parseInstructionsHeading (line: string) (recipe: Recipe) : Result = +let parseInstructions (line: string) (recipe: Recipe) : Result = let separator = if recipe.Instructions.Length = 0 then "" else "\n" Ok (ReadingInstructions, { recipe with Instructions = recipe.Instructions + separator + line }) -let parseLine (stateRecipe: Result) (line: string) : Result = - match stateRecipe with +let parseLine (prevResult: Result) (line: string) : Result = + match prevResult with | Ok (state, recipe) -> let lineParser = match state with @@ -85,13 +79,13 @@ let parseLine (stateRecipe: Result) (line: stri | SeekingIngredientsHeading -> parseIngredientsHeading | ReadingIngredientsList -> parseIngredientsList | SeekingInstructionsHeading -> parseInstructionsHeading - | ReadingInstructions -> parseReadingInstructions + | ReadingInstructions -> parseInstructions lineParser line recipe | Error e -> Error e let parseLines (lines: string array): Result = - let initialState: ParseState = ReadingTitle - let initialRecipe: Recipe = { Title = ""; Ingredients = []; Instructions = "" } + let initialState = ReadingTitle + let initialRecipe = { Title = ""; Ingredients = []; Instructions = "" } let result = lines |> Array.fold parseLine (Ok (initialState, initialRecipe)) match result with | Ok (_, recipe) -> Ok recipe @@ -99,8 +93,7 @@ let parseLines (lines: string array): Result = let parse (input: string) : Result = match parseLines (input.Split('\n')) with - | Ok recipe -> - if recipe.Ingredients.Length = 0 then Error MissingIngredients - elif recipe.Instructions.Length = 0 then Error MissingInstructions - else Ok recipe - | Error e -> Error e + | Ok recipe when recipe.Title.Length = 0 -> Error MissingTitle + | Ok recipe when recipe.Ingredients.Length = 0 -> Error MissingIngredients + | Ok recipe when recipe.Instructions.Length = 0 -> Error MissingInstructions + | result -> result From b8751b869b3d071f9a4f448fa87b06ea8733d0fe Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Thu, 22 Jan 2026 15:48:03 -0800 Subject: [PATCH 07/10] Rename exemplar file --- .../concept/family-recipes/.meta/{exemplar.fs => Exemplar.fs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename exercises/concept/family-recipes/.meta/{exemplar.fs => Exemplar.fs} (100%) diff --git a/exercises/concept/family-recipes/.meta/exemplar.fs b/exercises/concept/family-recipes/.meta/Exemplar.fs similarity index 100% rename from exercises/concept/family-recipes/.meta/exemplar.fs rename to exercises/concept/family-recipes/.meta/Exemplar.fs From 448c06d41e3997e2a69ce0046a0d72820a1dc4d9 Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Thu, 22 Jan 2026 15:48:23 -0800 Subject: [PATCH 08/10] Modify test content to be non-alcoholic --- .../family-recipes/FamilyRecipesTests.fs | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/exercises/concept/family-recipes/FamilyRecipesTests.fs b/exercises/concept/family-recipes/FamilyRecipesTests.fs index d74135a4d..862df51a4 100644 --- a/exercises/concept/family-recipes/FamilyRecipesTests.fs +++ b/exercises/concept/family-recipes/FamilyRecipesTests.fs @@ -49,49 +49,50 @@ let ``Error on missing ingredient item`` () = [] let ``Minimal valid recipe`` () = let expected: Result = Ok { - Title = "Glass of Wine" + Title = "Glass of Apple Juice" Ingredients = [ - { Quantity = 1; Item = "cup of wine" } + { Quantity = 1; Item = "cup of apple juice" } ] - Instructions = "Pour wine into wine glass.\n" + Instructions = "Pour apple juice into tumbler.\n" } - let input = """Glass of Wine + let input = """Glass of Apple Juice Ingredients: -1 cup of wine +1 cup of apple juice Instructions: -Pour wine into wine glass. +Pour apple juice into tumbler. """ parse input |> should equal expected [] let ``Valid recipe with multiple ingredients, integer quantities and varying units`` () = let expected: Result = Ok { - Title = "Gin and Tonic" + Title = "Bowl of Oatmeal" Ingredients = [ - { Quantity = 1; Item = "cup tonic water" }; - { Quantity = 2; Item = "shots of gin" }; - { Quantity = 5; Item = "cubes of ice" }; + { Quantity = 4; Item = "ounces of oatmeal" }; + { Quantity = 12; Item = "ounces of water" }; + { Quantity = 1; Item = "tablespoon of honey" }; ] - Instructions = """Put ice cubes into a glass. -Stir tonic water and gin in another glass. -Pour tonic water and gin mixture into glass with ice. + Instructions = """Bring water to boil in a small pot. +Stir oatmeal into boiling water. +Reduce heat and cook for 10 minutes. +Pour cooked oatmeal into a bowl. +Serve with honey. """ } - let input = """Gin and Tonic + let input = """Bowl of Oatmeal Ingredients: -1 cup tonic water -2 shots of gin -5 cubes of ice +4 ounces of oatmeal +12 ounces of water +1 tablespoon of honey Instructions: -Put ice cubes into a glass. -Stir tonic water and gin in another glass. -Pour tonic water and gin mixture into glass with ice. +Bring water to boil in a small pot. +Stir oatmeal into boiling water. +Reduce heat and cook for 10 minutes. +Pour cooked oatmeal into a bowl. +Serve with honey. """ parse input |> should equal expected - -// TODO: (maybe) Test the ability to parse fractional quantities -// TODO: Organize into tasks From bf00b5c155827e66134b7aafb626b4349c9689c6 Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Thu, 22 Jan 2026 15:51:46 -0800 Subject: [PATCH 09/10] Remove redundant conditional --- exercises/concept/family-recipes/.meta/Exemplar.fs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/exercises/concept/family-recipes/.meta/Exemplar.fs b/exercises/concept/family-recipes/.meta/Exemplar.fs index 8c72a9f40..1c00772f9 100644 --- a/exercises/concept/family-recipes/.meta/Exemplar.fs +++ b/exercises/concept/family-recipes/.meta/Exemplar.fs @@ -49,9 +49,7 @@ let parseIngredientsHeading (line: string) (recipe: Recipe) : Result = - if recipe.Ingredients.Length = 0 && line.Length = 0 then - Error MissingIngredients - elif line.Length = 0 then + if line.Length = 0 then Ok (SeekingInstructionsHeading, recipe) else match parseIngredient line with From e299424686a8f982df24cc5dd03ae25f01fe10da Mon Sep 17 00:00:00 2001 From: Todd Schwartz Date: Sat, 24 Jan 2026 07:39:41 -0800 Subject: [PATCH 10/10] Reduce exercise scope --- .../concept/family-recipes/.meta/Exemplar.fs | 110 +++++------------- .../concept/family-recipes/FamilyRecipes.fs | 21 +--- .../family-recipes/FamilyRecipesTests.fs | 100 ++++------------ 3 files changed, 57 insertions(+), 174 deletions(-) diff --git a/exercises/concept/family-recipes/.meta/Exemplar.fs b/exercises/concept/family-recipes/.meta/Exemplar.fs index 1c00772f9..95e6d4a4a 100644 --- a/exercises/concept/family-recipes/.meta/Exemplar.fs +++ b/exercises/concept/family-recipes/.meta/Exemplar.fs @@ -1,97 +1,41 @@ module FamilyRecipes -type ParseError = -| MissingTitle -| MissingIngredients -| MissingInstructions +type ValidationError = +| EmptyList | InvalidIngredientQuantity | MissingIngredientItem -type Ingredient = { - Quantity: int - Item: string -} +let parseInt (text: string) : Result = + let success, value = System.Int32.TryParse text + if success then Ok value else Error () -type Recipe = { - Title: string - Ingredients: Ingredient list - Instructions: string -} - -type ParseState = -| ReadingTitle -| SeekingIngredientsHeading -| ReadingIngredientsList -| SeekingInstructionsHeading -| ReadingInstructions - - -let splitOnce (text: string) (separator: char): (string * string) option = +let splitOnce (text: string) (separator: char) : (string * string) option = match text.IndexOf(separator) with | -1 -> None | index -> Some (text.Substring(0, index), text.Substring(index + 1)) -let parseIngredient (text: string): Result = +let validateIngredient (text: string): Result = match splitOnce text ' ' with - | Some (head, tail) -> - let success, quantity = System.Int32.TryParse head - if success then - Ok { Quantity = quantity; Item = tail } - else - Error InvalidIngredientQuantity + | Some (quantity, item) -> + match parseInt quantity with + | Ok _ -> + if item.Length = 0 then + Error MissingIngredientItem + else + Ok () + | Error _ -> Error InvalidIngredientQuantity | _ -> Error MissingIngredientItem -let parseTitle (line: string) (recipe: Recipe) : Result = - Ok (SeekingIngredientsHeading, { recipe with Title = line }) - -let parseIngredientsHeading (line: string) (recipe: Recipe) : Result = - let nextState = if line = "Ingredients:" then ReadingIngredientsList else SeekingIngredientsHeading - Ok (nextState, recipe) - -let parseIngredientsList (line: string) (recipe: Recipe) : Result = - if line.Length = 0 then - Ok (SeekingInstructionsHeading, recipe) +let validate (input: string) : Result = + let nonblankLines = + input.Split('\n') + |> Array.filter (fun l -> l.Length > 0) + if nonblankLines.Length = 0 then Error EmptyList else - match parseIngredient line with - | Ok ingredient -> Ok (ReadingIngredientsList, { - recipe with Ingredients = recipe.Ingredients @ [ingredient] - }) - | Error e -> Error e - -let parseInstructionsHeading (line: string) (recipe: Recipe) : Result = - let nextState = if line = "Instructions:" then ReadingInstructions else SeekingInstructionsHeading - Ok (nextState, recipe) - -let parseInstructions (line: string) (recipe: Recipe) : Result = - let separator = if recipe.Instructions.Length = 0 then "" else "\n" - Ok (ReadingInstructions, { - recipe with Instructions = recipe.Instructions + separator + line - }) - -let parseLine (prevResult: Result) (line: string) : Result = - match prevResult with - | Ok (state, recipe) -> - let lineParser = - match state with - | ReadingTitle -> parseTitle - | SeekingIngredientsHeading -> parseIngredientsHeading - | ReadingIngredientsList -> parseIngredientsList - | SeekingInstructionsHeading -> parseInstructionsHeading - | ReadingInstructions -> parseInstructions - lineParser line recipe - | Error e -> Error e - -let parseLines (lines: string array): Result = - let initialState = ReadingTitle - let initialRecipe = { Title = ""; Ingredients = []; Instructions = "" } - let result = lines |> Array.fold parseLine (Ok (initialState, initialRecipe)) - match result with - | Ok (_, recipe) -> Ok recipe - | Error e -> Error e - -let parse (input: string) : Result = - match parseLines (input.Split('\n')) with - | Ok recipe when recipe.Title.Length = 0 -> Error MissingTitle - | Ok recipe when recipe.Ingredients.Length = 0 -> Error MissingIngredients - | Ok recipe when recipe.Instructions.Length = 0 -> Error MissingInstructions - | result -> result + let firstError = + nonblankLines + |> Array.map validateIngredient + |> Array.tryFind (fun r -> r.IsError) + match firstError with + | Some (Error error) -> Error error + | _ -> Ok input diff --git a/exercises/concept/family-recipes/FamilyRecipes.fs b/exercises/concept/family-recipes/FamilyRecipes.fs index f94cd701d..c09d7effe 100644 --- a/exercises/concept/family-recipes/FamilyRecipes.fs +++ b/exercises/concept/family-recipes/FamilyRecipes.fs @@ -1,22 +1,13 @@ module FamilyRecipes -type ParseError = -| MissingTitle -| MissingIngredients -| MissingInstructions +type ValidationError = +| EmptyList | InvalidIngredientQuantity | MissingIngredientItem -type Ingredient = { - Quantity: int - Item: string -} +let parseInt (text: string) : Result = + let success, value = System.Int32.TryParse text + if success then Ok value else Error () -type Recipe = { - Title: string - Ingredients: Ingredient list - Instructions: string -} - -let parse input = +let validate input = failwith "Please implement this function" diff --git a/exercises/concept/family-recipes/FamilyRecipesTests.fs b/exercises/concept/family-recipes/FamilyRecipesTests.fs index 862df51a4..0054a1452 100644 --- a/exercises/concept/family-recipes/FamilyRecipesTests.fs +++ b/exercises/concept/family-recipes/FamilyRecipesTests.fs @@ -6,93 +6,41 @@ open Xunit open FamilyRecipes [] -let ``Error on blank recipe`` () = - let expected: Result = Error MissingTitle - parse "" |> should equal expected +let ``Error on blank list`` () = + let expected: Result = Error EmptyList + validate "" |> should equal expected [] -let ``Error on title without ingredients or instructions`` () = - let expected: Result = Error MissingIngredients - parse "foo" |> should equal expected - -[] -let ``Error on title and ingredients heading without ingredients list`` () = - let expected: Result = Error MissingIngredients - parse "A Title\n\nIngredients:" |> should equal expected - -[] -let ``Error on title and ingredients without instructions`` () = - let expected: Result = Error MissingInstructions - parse "A Title\n\nIngredients:\n1 ingredient" |> should equal expected - -[] -let ``Error on all required sections but with empty ingredients list`` () = - let expected: Result = Error MissingIngredients - parse "A Title\n\nIngredients:\n\nInstructions:\nSome instructions" |> should equal expected - -[] -let ``Error on all required sections but missing instructions`` () = - let expected: Result = Error MissingInstructions - parse "A Title\n\nIngredients:\n1 ingredient\n\nInstructions:" |> should equal expected +let ``Error on blank line`` () = + let expected: Result = Error EmptyList + validate "\n" |> should equal expected [] let ``Error on non-numeric ingredient quantity`` () = - let expected: Result = Error InvalidIngredientQuantity - parse "A Title\n\nIngredients:\nfoo bar\n\nInstructions:\nbuzz" |> should equal expected + let expected: Result = Error InvalidIngredientQuantity + validate "foo bar" |> should equal expected [] let ``Error on missing ingredient item`` () = - let expected: Result = Error MissingIngredientItem - parse "A Title\n\nIngredients:\n24\n\nInstructions:\nbuzz" |> should equal expected - + let expected: Result = Error MissingIngredientItem + validate "24 " |> should equal expected [] -let ``Minimal valid recipe`` () = - let expected: Result = Ok { - Title = "Glass of Apple Juice" - Ingredients = [ - { Quantity = 1; Item = "cup of apple juice" } - ] - Instructions = "Pour apple juice into tumbler.\n" - } - let input = """Glass of Apple Juice - -Ingredients: -1 cup of apple juice - -Instructions: -Pour apple juice into tumbler. -""" - parse input |> should equal expected +let ``Minimal valid list`` () = + let input = """1 cup rice""" + let expected: Result = Ok input + validate input |> should equal expected [] -let ``Valid recipe with multiple ingredients, integer quantities and varying units`` () = - let expected: Result = Ok { - Title = "Bowl of Oatmeal" - Ingredients = [ - { Quantity = 4; Item = "ounces of oatmeal" }; - { Quantity = 12; Item = "ounces of water" }; - { Quantity = 1; Item = "tablespoon of honey" }; - ] - Instructions = """Bring water to boil in a small pot. -Stir oatmeal into boiling water. -Reduce heat and cook for 10 minutes. -Pour cooked oatmeal into a bowl. -Serve with honey. -""" - } - let input = """Bowl of Oatmeal - -Ingredients: -4 ounces of oatmeal +let ``Valid list with multiple ingredients`` () = + let input = """4 ounces of oatmeal 12 ounces of water -1 tablespoon of honey +1 tablespoon of honey""" + let expected: Result = Ok input + validate input |> should equal expected -Instructions: -Bring water to boil in a small pot. -Stir oatmeal into boiling water. -Reduce heat and cook for 10 minutes. -Pour cooked oatmeal into a bowl. -Serve with honey. -""" - parse input |> should equal expected +[] +let ``Blank lines within valid list are OK`` () = + let input = "1 foo\n2 bar" + let expected: Result = Ok input + validate input |> should equal expected