Packages
cooklang_ex
0.0.1-alpha3
Elixir bindings for the canonical Cooklang parser (cooklang-rs). Parse, scale, and convert Cooklang recipes with full support for extensions.
Current section
Files
Jump to
Current section
Files
lib/cooklang_ex.ex
defmodule CooklangEx do
@moduledoc """
Elixir bindings for the canonical Cooklang parser.
This library wraps the `cooklang-rs` Rust crate via Rustler NIFs,
providing a complete Cooklang parser with support for:
- Parsing `.cook` recipe files
- Recipe scaling (adjust servings)
- Unit conversion
- Rich error messages
- Optional Cooklang extensions
## Quick Start
iex> recipe_text = \"""
...> >> servings: 2
...>
...> Crack @eggs{3} into a #bowl{} and whisk.
...> Heat @butter{2%tbsp} in a #pan{large} over medium heat.
...> Cook for ~{5%minutes}.
...> \"""
iex> {:ok, recipe} = CooklangEx.parse(recipe_text)
iex> recipe.metadata["servings"]
"2"
iex> length(recipe.ingredients)
2
## Scaling Recipes
iex> recipe_text = \"""
...> >> servings: 2
...>
...> Crack @eggs{3} into a #bowl{} and whisk.
...> Heat @butter{2%tbsp} in a #pan{large} over medium heat.
...> Cook for ~{5%minutes}.
...> \"""
iex> {:ok, scaled} = CooklangEx.parse_and_scale(recipe_text, 4)
iex> Enum.find(scaled.ingredients, &(&1.name == "eggs")).quantity.value
6.0
## Extensions
The parser supports several extensions to the base Cooklang spec.
These can be enabled/disabled via options:
CooklangEx.parse(text, extensions: [:multiline_steps, :advanced_units])
See `CooklangEx.Extensions` for available extensions.
"""
alias CooklangEx.Native
alias CooklangEx.Recipe
@type parse_option ::
{:extensions, [atom()]}
| {:all_extensions, boolean()}
@type scale_option ::
{:target_servings, pos_integer()}
@doc """
Parse a Cooklang recipe string.
Returns `{:ok, recipe}` on success or `{:error, reason}` on failure.
## Options
- `:extensions` - List of extensions to enable (default: all)
- `:all_extensions` - Enable all extensions (default: true)
## Examples
iex> {:ok, recipe} = CooklangEx.parse("Add @salt{1%tsp} and @pepper{} to taste.")
iex> is_struct(recipe, CooklangEx.Recipe)
true
"""
@spec parse(String.t(), [parse_option()]) :: {:ok, Recipe.t()} | {:error, String.t()}
def parse(input, opts \\ []) when is_binary(input) do
all_extensions = Keyword.get(opts, :all_extensions, true)
case Native.parse(input, all_extensions) do
{:ok, json} ->
{:ok, Recipe.from_json(json)}
{:error, _} = error ->
error
end
end
@doc """
Parse a Cooklang recipe string, raising on error.
See `parse/2` for options.
"""
@spec parse!(String.t(), [parse_option()]) :: Recipe.t()
def parse!(input, opts \\ []) do
case parse(input, opts) do
{:ok, recipe} -> recipe
{:error, reason} -> raise ArgumentError, reason
end
end
@doc """
Parse and scale a recipe to a target number of servings.
The recipe must have a `servings` metadata field to enable scaling.
## Examples
iex> text = \"""
...> >> servings: 2
...> Add @flour{200%g} to a #bowl{}.
...> \"""
iex> {:ok, recipe} = CooklangEx.parse_and_scale(text, 4)
iex> hd(recipe.ingredients).quantity.value
400.0
"""
@spec parse_and_scale(String.t(), pos_integer(), [parse_option()]) ::
{:ok, Recipe.t()} | {:error, String.t()}
def parse_and_scale(input, target_servings, opts \\ [])
when is_binary(input) and is_integer(target_servings) and target_servings > 0 do
all_extensions = Keyword.get(opts, :all_extensions, true)
case Native.parse_and_scale(input, target_servings, all_extensions) do
{:ok, json} ->
{:ok, Recipe.from_json(json)}
{:error, _} = error ->
error
end
end
@doc """
Parse and scale a recipe, raising on error.
See `parse_and_scale/3` for details.
"""
@spec parse_and_scale!(String.t(), pos_integer(), [parse_option()]) :: Recipe.t()
def parse_and_scale!(input, target_servings, opts \\ []) do
case parse_and_scale(input, target_servings, opts) do
{:ok, recipe} -> recipe
{:error, reason} -> raise ArgumentError, reason
end
end
@doc """
Extract just the ingredients from a recipe string.
Useful when you only need an ingredient list without full parsing.
## Examples
iex> {:ok, ingredients} = CooklangEx.ingredients("Add @eggs{3} and @milk{1%cup}.")
iex> Enum.map(ingredients, & &1.name)
["eggs", "milk"]
"""
@spec ingredients(String.t()) :: {:ok, [Recipe.Ingredient.t()]} | {:error, String.t()}
def ingredients(input) when is_binary(input) do
case Native.parse(input, true) do
{:ok, json} ->
recipe = Recipe.from_json(json)
{:ok, recipe.ingredients}
{:error, _} = error ->
error
end
end
@doc """
Extract just the cookware from a recipe string.
## Examples
iex> {:ok, cookware} = CooklangEx.cookware("Heat in a #pan{large} and transfer to a #bowl{}.")
iex> Enum.map(cookware, & &1.name)
["pan", "bowl"]
"""
@spec cookware(String.t()) :: {:ok, [Recipe.Cookware.t()]} | {:error, String.t()}
def cookware(input) when is_binary(input) do
case Native.parse(input, true) do
{:ok, json} ->
recipe = Recipe.from_json(json)
{:ok, recipe.cookware}
{:error, _} = error ->
error
end
end
@doc """
Extract the metadata from a recipe string.
## Examples
iex> text = \"""
...> >> source: https://example.com
...> >> servings: 4
...> >> time: 30 minutes
...> \"""
iex> {:ok, metadata} = CooklangEx.metadata(text)
iex> metadata["servings"]
"4"
"""
@spec metadata(String.t()) :: {:ok, map()} | {:error, String.t()}
def metadata(input) when is_binary(input) do
case Native.parse(input, true) do
{:ok, json} ->
recipe = Recipe.from_json(json)
{:ok, recipe.metadata}
{:error, _} = error ->
error
end
end
@doc """
Parse an aisle configuration file.
Aisle configuration files organize ingredients into shopping categories
(aisles) for easier grocery shopping.
Returns `{:ok, config}` on success or `{:error, reason}` on failure.
## Format
Aisle configuration files use a simple INI-like format:
[produce]
tomatoes
onions
garlic
[dairy]
milk
cheese
butter
## Examples
iex> config = \"""
...> [produce]
...> tomatoes
...> onions
...> \"""
iex> {:ok, result} = CooklangEx.parse_aisle_config(config)
iex> is_map(Jason.decode!(result))
true
"""
@spec parse_aisle_config(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def parse_aisle_config(input) when is_binary(input) do
Native.parse_aisle_config(input)
end
@doc """
Parse an aisle configuration file, raising on error.
See `parse_aisle_config/1` for details.
"""
@spec parse_aisle_config!(String.t()) :: map()
def parse_aisle_config!(input) do
case parse_aisle_config(input) do
{:ok, json} -> Jason.decode!(json)
{:error, reason} -> raise ArgumentError, reason
end
end
end