Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions cooklang.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,20 +244,24 @@ func (Comment) isStepComponent() {}
func (Note) isStepComponent() {}

// Render returns the Cooklang syntax representation of this ingredient.
// Examples: "@flour{500%g}", "@salt{}", "@milk{2%cups}(cold)", "@yeast{=1%packet}"
// Examples: "@flour{500%g}", "@salt{}", "@milk{2%cups}(cold)", "@yeast{=1%packet}", "@?thyme{2%sprigs}"
func (i Ingredient) Render() string {
var result string
prefix := "@"
if i.Optional {
prefix = "@?"
}
fixedPrefix := ""
if i.Fixed {
fixedPrefix = "="
}
if i.Quantity > 0 {
result = fmt.Sprintf("@%s{%s%g%%%s}", i.Name, fixedPrefix, i.Quantity, i.Unit)
result = fmt.Sprintf("%s%s{%s%g%%%s}", prefix, i.Name, fixedPrefix, i.Quantity, i.Unit)
} else if i.Quantity == -1 {
// -1 indicates "some" quantity
result = fmt.Sprintf("@%s{}", i.Name)
result = fmt.Sprintf("%s%s{}", prefix, i.Name)
} else {
result = fmt.Sprintf("@%s{}", i.Name)
result = fmt.Sprintf("%s%s{}", prefix, i.Name)
}
if i.Annotation != "" {
result += fmt.Sprintf("(%s)", i.Annotation)
Expand All @@ -266,9 +270,10 @@ func (i Ingredient) Render() string {
}

// RenderDisplay returns ingredient in plain text format suitable for display.
// Examples: "2 cups flour", "500 g flour", "salt"
// Examples: "2 cups flour", "500 g flour", "salt", "2 sprigs thyme (optional)"
// Uses bartender-friendly fraction formatting (e.g., "1/2 oz" instead of "0.5 oz")
// When quantity is unspecified (e.g., @salt{}), returns just the ingredient name.
// Optional ingredients have "(optional)" appended.
func (i Ingredient) RenderDisplay() string {
var result string
if i.Quantity > 0 && i.Unit != "" {
Expand All @@ -281,6 +286,9 @@ func (i Ingredient) RenderDisplay() string {
// Quantity == -1 (unspecified) or 0: just use the ingredient name
result = i.Name
}
if i.Optional {
result += " (optional)"
}
return result
}

Expand Down Expand Up @@ -394,11 +402,13 @@ func (n Note) RenderDisplay() string {
//
// The Quantity field uses -1 to represent "some" (unspecified amount).
// The Fixed field indicates a quantity that should not scale with servings (e.g., @salt{=1%tsp}).
// The Optional field indicates an optional ingredient (e.g., @?thyme{2%sprigs}).
type Ingredient struct {
Name string `json:"name,omitempty"` // Ingredient name (e.g., "flour", "sugar")
Quantity float32 `json:"quantity,omitempty"` // Amount (-1 means "some", 0 means none specified)
Unit string `json:"unit,omitempty"` // Unit of measurement (e.g., "g", "cup", "tbsp")
Fixed bool `json:"fixed,omitempty"` // Fixed quantity doesn't scale with servings
Optional bool `json:"optional,omitempty"` // Optional ingredient (can be omitted)
TypedUnit *units.Unit `json:"typed_unit,omitempty"` // Typed unit for conversion operations
Subinstruction string `json:"value,omitempty"` // Additional preparation instructions
Annotation string `json:"annotation,omitempty"` // Optional annotation (e.g., "finely chopped")
Expand Down Expand Up @@ -780,6 +790,7 @@ func ToCooklangRecipe(pRecipe *parser.Recipe) *Recipe {
Quantity: quant,
Unit: component.Unit,
Fixed: component.Fixed,
Optional: component.Optional,
TypedUnit: CreateTypedUnit(component.Unit),
Annotation: component.Value,
}
Expand Down
32 changes: 32 additions & 0 deletions example_recipes/Gin_and_Tonic.cook
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: Gin and Tonic
category: Cocktails
sub_category: Classic
cuisine: British
locale: en
tags:
- classic
- refreshing
- gin
- highball
- summer
servings: 1
time: 3 minutes
image: Gin_and_Tonic.jpg
---

-- The classic G&T with optional garnish variations

Fill a #highball glass{} with @ice cubes{}.

Pour @gin{50%ml}(London Dry style works best) over the ice.

Top with @tonic water{150%ml}(premium tonic recommended) and stir gently for ~{5%seconds}.

= Garnish =

Add @?lime wedge{1} or @?lemon wheel{1} for citrus brightness.

For a botanical twist, try @?cucumber ribbon{1} or @?fresh rosemary{1%sprig}.

> Tip: The garnish should complement your gin's botanicals. Citrus-forward gins pair well with lime, while floral gins shine with cucumber or herbs.
16 changes: 14 additions & 2 deletions lexer/lexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,20 @@ func (l *Lexer) NextToken() token.Token {
case '}':
tok = newToken(token.RBRACE, l.ch)
case '@':
// Only treat as INGREDIENT if immediately followed by an identifier character or underscore
if isIdentifierChar(l.peekChar()) || l.peekChar() == '_' {
// Check for optional ingredient: @?
if l.peekChar() == '?' {
// Peek at the character after the '?' to see if it's an identifier
charAfterQuestion := l.peekCharAt(1)
if isIdentifierChar(charAfterQuestion) || charAfterQuestion == '_' {
ch := l.ch
l.readChar() // consume '?'
tok = token.Token{Type: token.OPTIONAL_INGREDIENT, Literal: string(ch) + string(l.ch)}
} else {
// @? not followed by identifier - treat @ as text
tok = newToken(token.ILLEGAL, l.ch)
}
} else if isIdentifierChar(l.peekChar()) || l.peekChar() == '_' {
// Only treat as INGREDIENT if immediately followed by an identifier character or underscore
tok = newToken(token.INGREDIENT, l.ch)
} else {
// Treat as regular text if followed by whitespace or other characters
Expand Down
131 changes: 131 additions & 0 deletions lexer/lexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,3 +650,134 @@ func TestNote(t *testing.T) {
})
}
}

// TestOptionalIngredient tests optional ingredient tokenization @?
func TestOptionalIngredient(t *testing.T) {
tests := []struct {
name string
input string
expectedTokens []struct {
tokenType token.TokenType
literal string
}
}{
{
name: "simple optional ingredient",
input: "@?thyme",
expectedTokens: []struct {
tokenType token.TokenType
literal string
}{
{token.OPTIONAL_INGREDIENT, "@?"},
{token.IDENT, "thyme"},
{token.EOF, ""},
},
},
{
name: "optional ingredient with braces",
input: "@?thyme{2%sprigs}",
expectedTokens: []struct {
tokenType token.TokenType
literal string
}{
{token.OPTIONAL_INGREDIENT, "@?"},
{token.IDENT, "thyme"},
{token.LBRACE, "{"},
{token.IDENT, "2"},
{token.PERCENT, "%"},
{token.IDENT, "sprigs"},
{token.RBRACE, "}"},
{token.EOF, ""},
},
},
{
name: "optional ingredient inline",
input: "Add @?parsley for garnish",
expectedTokens: []struct {
tokenType token.TokenType
literal string
}{
{token.IDENT, "Add"},
{token.WHITESPACE, " "},
{token.OPTIONAL_INGREDIENT, "@?"},
{token.IDENT, "parsley"},
{token.WHITESPACE, " "},
{token.IDENT, "for"},
{token.WHITESPACE, " "},
{token.IDENT, "garnish"},
{token.EOF, ""},
},
},
{
name: "@? not followed by identifier - treated as text",
input: "Use @? as placeholder",
expectedTokens: []struct {
tokenType token.TokenType
literal string
}{
{token.IDENT, "Use"},
{token.WHITESPACE, " "},
{token.ILLEGAL, "@"},
{token.ILLEGAL, "?"},
{token.WHITESPACE, " "},
{token.IDENT, "as"},
{token.WHITESPACE, " "},
{token.IDENT, "placeholder"},
{token.EOF, ""},
},
},
{
name: "@? at end of input",
input: "test @?",
expectedTokens: []struct {
tokenType token.TokenType
literal string
}{
{token.IDENT, "test"},
{token.WHITESPACE, " "},
{token.ILLEGAL, "@"},
{token.ILLEGAL, "?"},
{token.EOF, ""},
},
},
{
name: "mixed regular and optional ingredients",
input: "@flour{500%g} and @?herbs{}",
expectedTokens: []struct {
tokenType token.TokenType
literal string
}{
{token.INGREDIENT, "@"},
{token.IDENT, "flour"},
{token.LBRACE, "{"},
{token.IDENT, "500"},
{token.PERCENT, "%"},
{token.IDENT, "g"},
{token.RBRACE, "}"},
{token.WHITESPACE, " "},
{token.IDENT, "and"},
{token.WHITESPACE, " "},
{token.OPTIONAL_INGREDIENT, "@?"},
{token.IDENT, "herbs"},
{token.LBRACE, "{"},
{token.RBRACE, "}"},
{token.EOF, ""},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := New(tt.input)
for i, expected := range tt.expectedTokens {
tok := l.NextToken()
if tok.Type != expected.tokenType {
t.Errorf("token[%d]: expected type %s, got %s", i, expected.tokenType, tok.Type)
}
if tok.Literal != expected.literal {
t.Errorf("token[%d]: expected literal %q, got %q", i, expected.literal, tok.Literal)
}
}
})
}
}
13 changes: 10 additions & 3 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ type Component struct {
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Quantity string `json:"quantity,omitempty" yaml:"quantity,omitempty"`
Unit string `json:"unit,omitempty" yaml:"units,omitempty"`
Fixed bool `json:"fixed,omitempty" yaml:"fixed,omitempty"` // Fixed quantity doesn't scale with servings
Fixed bool `json:"fixed,omitempty" yaml:"fixed,omitempty"` // Fixed quantity doesn't scale with servings
Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"` // Optional ingredient
}

// CooklangParser handles parsing of cooklang recipes
Expand Down Expand Up @@ -113,11 +114,14 @@ func (p *CooklangParser) parseTokens(l *lexer.Lexer) (*Recipe, error) {
}
// Process the next token immediately here
switch nextTok.Type {
case token.INGREDIENT:
case token.INGREDIENT, token.OPTIONAL_INGREDIENT:
ingredient, err := p.parseIngredient(l)
if err != nil {
return nil, fmt.Errorf("failed to parse ingredient: %w", err)
}
if nextTok.Type == token.OPTIONAL_INGREDIENT {
ingredient.Optional = true
}
currentStep.Components = append(currentStep.Components, ingredient)
case token.COOKWARE:
cookware, err := p.parseCookware(l)
Expand Down Expand Up @@ -240,12 +244,15 @@ func (p *CooklangParser) parseTokens(l *lexer.Lexer) (*Recipe, error) {
recipe.Steps = append(recipe.Steps, currentStep)
currentStep = Step{Components: []Component{}}

case token.INGREDIENT:
case token.INGREDIENT, token.OPTIONAL_INGREDIENT:
// Parse ingredient
ingredient, err := p.parseIngredient(l)
if err != nil {
return nil, fmt.Errorf("failed to parse ingredient: %w", err)
}
if tok.Type == token.OPTIONAL_INGREDIENT {
ingredient.Optional = true
}
currentStep.Components = append(currentStep.Components, ingredient)

case token.COOKWARE:
Expand Down
Loading