Skip to content

Commit 7e41a0f

Browse files
authored
feat: add @? optional ingredient syntax (#22)
Implement the @? operator for marking ingredients as optional, following the cooklang-rs extension convention. Optional ingredients are displayed with '(optional)' suffix in all output formats. Changes: - Add OPTIONAL_INGREDIENT token type and lexer support - Update parser to handle @? and set Optional field on components - Add Optional bool field to Ingredient struct - Update Render() and RenderDisplay() methods for optional ingredients - Add optional ingredient support to HTML, Markdown, and Print renderers - Add spec tests and parser/lexer unit tests - Add example Gin_and_Tonic.cook recipe demonstrating optional garnishes Example usage: Add @?thyme{2%sprigs} if desired. Garnish with @?lime wedge{1} or @?lemon wheel{1}.
1 parent a702a45 commit 7e41a0f

11 files changed

Lines changed: 478 additions & 23 deletions

File tree

cooklang.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,20 +244,24 @@ func (Comment) isStepComponent() {}
244244
func (Note) isStepComponent() {}
245245

246246
// Render returns the Cooklang syntax representation of this ingredient.
247-
// Examples: "@flour{500%g}", "@salt{}", "@milk{2%cups}(cold)", "@yeast{=1%packet}"
247+
// Examples: "@flour{500%g}", "@salt{}", "@milk{2%cups}(cold)", "@yeast{=1%packet}", "@?thyme{2%sprigs}"
248248
func (i Ingredient) Render() string {
249249
var result string
250+
prefix := "@"
251+
if i.Optional {
252+
prefix = "@?"
253+
}
250254
fixedPrefix := ""
251255
if i.Fixed {
252256
fixedPrefix = "="
253257
}
254258
if i.Quantity > 0 {
255-
result = fmt.Sprintf("@%s{%s%g%%%s}", i.Name, fixedPrefix, i.Quantity, i.Unit)
259+
result = fmt.Sprintf("%s%s{%s%g%%%s}", prefix, i.Name, fixedPrefix, i.Quantity, i.Unit)
256260
} else if i.Quantity == -1 {
257261
// -1 indicates "some" quantity
258-
result = fmt.Sprintf("@%s{}", i.Name)
262+
result = fmt.Sprintf("%s%s{}", prefix, i.Name)
259263
} else {
260-
result = fmt.Sprintf("@%s{}", i.Name)
264+
result = fmt.Sprintf("%s%s{}", prefix, i.Name)
261265
}
262266
if i.Annotation != "" {
263267
result += fmt.Sprintf("(%s)", i.Annotation)
@@ -266,9 +270,10 @@ func (i Ingredient) Render() string {
266270
}
267271

268272
// RenderDisplay returns ingredient in plain text format suitable for display.
269-
// Examples: "2 cups flour", "500 g flour", "salt"
273+
// Examples: "2 cups flour", "500 g flour", "salt", "2 sprigs thyme (optional)"
270274
// Uses bartender-friendly fraction formatting (e.g., "1/2 oz" instead of "0.5 oz")
271275
// When quantity is unspecified (e.g., @salt{}), returns just the ingredient name.
276+
// Optional ingredients have "(optional)" appended.
272277
func (i Ingredient) RenderDisplay() string {
273278
var result string
274279
if i.Quantity > 0 && i.Unit != "" {
@@ -281,6 +286,9 @@ func (i Ingredient) RenderDisplay() string {
281286
// Quantity == -1 (unspecified) or 0: just use the ingredient name
282287
result = i.Name
283288
}
289+
if i.Optional {
290+
result += " (optional)"
291+
}
284292
return result
285293
}
286294

@@ -394,11 +402,13 @@ func (n Note) RenderDisplay() string {
394402
//
395403
// The Quantity field uses -1 to represent "some" (unspecified amount).
396404
// The Fixed field indicates a quantity that should not scale with servings (e.g., @salt{=1%tsp}).
405+
// The Optional field indicates an optional ingredient (e.g., @?thyme{2%sprigs}).
397406
type Ingredient struct {
398407
Name string `json:"name,omitempty"` // Ingredient name (e.g., "flour", "sugar")
399408
Quantity float32 `json:"quantity,omitempty"` // Amount (-1 means "some", 0 means none specified)
400409
Unit string `json:"unit,omitempty"` // Unit of measurement (e.g., "g", "cup", "tbsp")
401410
Fixed bool `json:"fixed,omitempty"` // Fixed quantity doesn't scale with servings
411+
Optional bool `json:"optional,omitempty"` // Optional ingredient (can be omitted)
402412
TypedUnit *units.Unit `json:"typed_unit,omitempty"` // Typed unit for conversion operations
403413
Subinstruction string `json:"value,omitempty"` // Additional preparation instructions
404414
Annotation string `json:"annotation,omitempty"` // Optional annotation (e.g., "finely chopped")
@@ -780,6 +790,7 @@ func ToCooklangRecipe(pRecipe *parser.Recipe) *Recipe {
780790
Quantity: quant,
781791
Unit: component.Unit,
782792
Fixed: component.Fixed,
793+
Optional: component.Optional,
783794
TypedUnit: CreateTypedUnit(component.Unit),
784795
Annotation: component.Value,
785796
}

example_recipes/Gin_and_Tonic.cook

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
title: Gin and Tonic
3+
category: Cocktails
4+
sub_category: Classic
5+
cuisine: British
6+
locale: en
7+
tags:
8+
- classic
9+
- refreshing
10+
- gin
11+
- highball
12+
- summer
13+
servings: 1
14+
time: 3 minutes
15+
image: Gin_and_Tonic.jpg
16+
---
17+
18+
-- The classic G&T with optional garnish variations
19+
20+
Fill a #highball glass{} with @ice cubes{}.
21+
22+
Pour @gin{50%ml}(London Dry style works best) over the ice.
23+
24+
Top with @tonic water{150%ml}(premium tonic recommended) and stir gently for ~{5%seconds}.
25+
26+
= Garnish =
27+
28+
Add @?lime wedge{1} or @?lemon wheel{1} for citrus brightness.
29+
30+
For a botanical twist, try @?cucumber ribbon{1} or @?fresh rosemary{1%sprig}.
31+
32+
> Tip: The garnish should complement your gin's botanicals. Citrus-forward gins pair well with lime, while floral gins shine with cucumber or herbs.

lexer/lexer.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,20 @@ func (l *Lexer) NextToken() token.Token {
128128
case '}':
129129
tok = newToken(token.RBRACE, l.ch)
130130
case '@':
131-
// Only treat as INGREDIENT if immediately followed by an identifier character or underscore
132-
if isIdentifierChar(l.peekChar()) || l.peekChar() == '_' {
131+
// Check for optional ingredient: @?
132+
if l.peekChar() == '?' {
133+
// Peek at the character after the '?' to see if it's an identifier
134+
charAfterQuestion := l.peekCharAt(1)
135+
if isIdentifierChar(charAfterQuestion) || charAfterQuestion == '_' {
136+
ch := l.ch
137+
l.readChar() // consume '?'
138+
tok = token.Token{Type: token.OPTIONAL_INGREDIENT, Literal: string(ch) + string(l.ch)}
139+
} else {
140+
// @? not followed by identifier - treat @ as text
141+
tok = newToken(token.ILLEGAL, l.ch)
142+
}
143+
} else if isIdentifierChar(l.peekChar()) || l.peekChar() == '_' {
144+
// Only treat as INGREDIENT if immediately followed by an identifier character or underscore
133145
tok = newToken(token.INGREDIENT, l.ch)
134146
} else {
135147
// Treat as regular text if followed by whitespace or other characters

lexer/lexer_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,3 +650,134 @@ func TestNote(t *testing.T) {
650650
})
651651
}
652652
}
653+
654+
// TestOptionalIngredient tests optional ingredient tokenization @?
655+
func TestOptionalIngredient(t *testing.T) {
656+
tests := []struct {
657+
name string
658+
input string
659+
expectedTokens []struct {
660+
tokenType token.TokenType
661+
literal string
662+
}
663+
}{
664+
{
665+
name: "simple optional ingredient",
666+
input: "@?thyme",
667+
expectedTokens: []struct {
668+
tokenType token.TokenType
669+
literal string
670+
}{
671+
{token.OPTIONAL_INGREDIENT, "@?"},
672+
{token.IDENT, "thyme"},
673+
{token.EOF, ""},
674+
},
675+
},
676+
{
677+
name: "optional ingredient with braces",
678+
input: "@?thyme{2%sprigs}",
679+
expectedTokens: []struct {
680+
tokenType token.TokenType
681+
literal string
682+
}{
683+
{token.OPTIONAL_INGREDIENT, "@?"},
684+
{token.IDENT, "thyme"},
685+
{token.LBRACE, "{"},
686+
{token.IDENT, "2"},
687+
{token.PERCENT, "%"},
688+
{token.IDENT, "sprigs"},
689+
{token.RBRACE, "}"},
690+
{token.EOF, ""},
691+
},
692+
},
693+
{
694+
name: "optional ingredient inline",
695+
input: "Add @?parsley for garnish",
696+
expectedTokens: []struct {
697+
tokenType token.TokenType
698+
literal string
699+
}{
700+
{token.IDENT, "Add"},
701+
{token.WHITESPACE, " "},
702+
{token.OPTIONAL_INGREDIENT, "@?"},
703+
{token.IDENT, "parsley"},
704+
{token.WHITESPACE, " "},
705+
{token.IDENT, "for"},
706+
{token.WHITESPACE, " "},
707+
{token.IDENT, "garnish"},
708+
{token.EOF, ""},
709+
},
710+
},
711+
{
712+
name: "@? not followed by identifier - treated as text",
713+
input: "Use @? as placeholder",
714+
expectedTokens: []struct {
715+
tokenType token.TokenType
716+
literal string
717+
}{
718+
{token.IDENT, "Use"},
719+
{token.WHITESPACE, " "},
720+
{token.ILLEGAL, "@"},
721+
{token.ILLEGAL, "?"},
722+
{token.WHITESPACE, " "},
723+
{token.IDENT, "as"},
724+
{token.WHITESPACE, " "},
725+
{token.IDENT, "placeholder"},
726+
{token.EOF, ""},
727+
},
728+
},
729+
{
730+
name: "@? at end of input",
731+
input: "test @?",
732+
expectedTokens: []struct {
733+
tokenType token.TokenType
734+
literal string
735+
}{
736+
{token.IDENT, "test"},
737+
{token.WHITESPACE, " "},
738+
{token.ILLEGAL, "@"},
739+
{token.ILLEGAL, "?"},
740+
{token.EOF, ""},
741+
},
742+
},
743+
{
744+
name: "mixed regular and optional ingredients",
745+
input: "@flour{500%g} and @?herbs{}",
746+
expectedTokens: []struct {
747+
tokenType token.TokenType
748+
literal string
749+
}{
750+
{token.INGREDIENT, "@"},
751+
{token.IDENT, "flour"},
752+
{token.LBRACE, "{"},
753+
{token.IDENT, "500"},
754+
{token.PERCENT, "%"},
755+
{token.IDENT, "g"},
756+
{token.RBRACE, "}"},
757+
{token.WHITESPACE, " "},
758+
{token.IDENT, "and"},
759+
{token.WHITESPACE, " "},
760+
{token.OPTIONAL_INGREDIENT, "@?"},
761+
{token.IDENT, "herbs"},
762+
{token.LBRACE, "{"},
763+
{token.RBRACE, "}"},
764+
{token.EOF, ""},
765+
},
766+
},
767+
}
768+
769+
for _, tt := range tests {
770+
t.Run(tt.name, func(t *testing.T) {
771+
l := New(tt.input)
772+
for i, expected := range tt.expectedTokens {
773+
tok := l.NextToken()
774+
if tok.Type != expected.tokenType {
775+
t.Errorf("token[%d]: expected type %s, got %s", i, expected.tokenType, tok.Type)
776+
}
777+
if tok.Literal != expected.literal {
778+
t.Errorf("token[%d]: expected literal %q, got %q", i, expected.literal, tok.Literal)
779+
}
780+
}
781+
})
782+
}
783+
}

parser/parser.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ type Component struct {
3030
Name string `json:"name,omitempty" yaml:"name,omitempty"`
3131
Quantity string `json:"quantity,omitempty" yaml:"quantity,omitempty"`
3232
Unit string `json:"unit,omitempty" yaml:"units,omitempty"`
33-
Fixed bool `json:"fixed,omitempty" yaml:"fixed,omitempty"` // Fixed quantity doesn't scale with servings
33+
Fixed bool `json:"fixed,omitempty" yaml:"fixed,omitempty"` // Fixed quantity doesn't scale with servings
34+
Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"` // Optional ingredient
3435
}
3536

3637
// CooklangParser handles parsing of cooklang recipes
@@ -113,11 +114,14 @@ func (p *CooklangParser) parseTokens(l *lexer.Lexer) (*Recipe, error) {
113114
}
114115
// Process the next token immediately here
115116
switch nextTok.Type {
116-
case token.INGREDIENT:
117+
case token.INGREDIENT, token.OPTIONAL_INGREDIENT:
117118
ingredient, err := p.parseIngredient(l)
118119
if err != nil {
119120
return nil, fmt.Errorf("failed to parse ingredient: %w", err)
120121
}
122+
if nextTok.Type == token.OPTIONAL_INGREDIENT {
123+
ingredient.Optional = true
124+
}
121125
currentStep.Components = append(currentStep.Components, ingredient)
122126
case token.COOKWARE:
123127
cookware, err := p.parseCookware(l)
@@ -240,12 +244,15 @@ func (p *CooklangParser) parseTokens(l *lexer.Lexer) (*Recipe, error) {
240244
recipe.Steps = append(recipe.Steps, currentStep)
241245
currentStep = Step{Components: []Component{}}
242246

243-
case token.INGREDIENT:
247+
case token.INGREDIENT, token.OPTIONAL_INGREDIENT:
244248
// Parse ingredient
245249
ingredient, err := p.parseIngredient(l)
246250
if err != nil {
247251
return nil, fmt.Errorf("failed to parse ingredient: %w", err)
248252
}
253+
if tok.Type == token.OPTIONAL_INGREDIENT {
254+
ingredient.Optional = true
255+
}
249256
currentStep.Components = append(currentStep.Components, ingredient)
250257

251258
case token.COOKWARE:

0 commit comments

Comments
 (0)