cooklang 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -1
- data/lib/cooklang/formatters/hash.rb +3 -1
- data/lib/cooklang/ingredient.rb +12 -5
- data/lib/cooklang/lexer.rb +4 -4
- data/lib/cooklang/parsers/ingredient_parser.rb +10 -3
- data/lib/cooklang/processors/step_processor.rb +1 -1
- data/lib/cooklang/version.rb +1 -1
- data/spec/formatters/hash_spec.rb +12 -0
- data/spec/lexer_spec.rb +4 -4
- data/spec/models/ingredient_spec.rb +59 -3
- data/spec/parser_spec.rb +67 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31ab63d47990eb2eaa6eb3dd09af9ba81d760cfbcfde937467f7060282195409
|
|
4
|
+
data.tar.gz: 2fb316c77fcfaabdc679943997cfa68607d0dbf61599def411743ff53be8f0a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7665a15f2b3ce79f5b7314068d58e1534e2dea35ee6268bbb2921a804972f8e366abe9d867e33fc4fd281d295f60ade7fac4903af263b5ab57fa88eba1fe29cf
|
|
7
|
+
data.tar.gz: 0cfaa9510dd5a81d68ba8205cdf497eb62f7983f2b03a645a0d1ebacd6eb4d4c30d13e5af09ea0f2b81e2d6a2687bd23d368b4e4d9b841d3633aff9a305cb991
|
data/README.md
CHANGED
|
@@ -281,7 +281,7 @@ timer.unit # "minutes"
|
|
|
281
281
|
|
|
282
282
|
## Supported Cooklang Features
|
|
283
283
|
|
|
284
|
-
- **Ingredients** (`@`): Single/multi-word names, quantities, units, preparation notes
|
|
284
|
+
- **Ingredients** (`@`): Single/multi-word names, quantities, units, preparation notes, fixed quantities
|
|
285
285
|
- **Cookware** (`#`): Single/multi-word names, quantities
|
|
286
286
|
- **Timers** (`~`): Anonymous and named timers
|
|
287
287
|
- **Comments**: Line (`--`) and block (`[- -]`) comments
|
|
@@ -289,6 +289,7 @@ timer.unit # "minutes"
|
|
|
289
289
|
- **Steps**: Paragraph-based with blank line separation
|
|
290
290
|
- **Notes** (`>`): Standalone notes
|
|
291
291
|
- **Sections** (`=`): Recipe sections with optional names
|
|
292
|
+
- **Canonical Tests** Passes all [Cooklang canonical tests](https://github.com/cooklang/spec/blob/main/tests/canonical.yaml) as of version 7
|
|
292
293
|
|
|
293
294
|
### Not Yet Implemented
|
|
294
295
|
|
|
@@ -231,7 +231,9 @@ module Cooklang
|
|
|
231
231
|
|
|
232
232
|
def format_ingredient_data
|
|
233
233
|
recipe.ingredients.map do |ingredient|
|
|
234
|
-
if ingredient.
|
|
234
|
+
if ingredient.fixed?
|
|
235
|
+
"fixed"
|
|
236
|
+
elsif ingredient.quantity && ingredient.quantity != "some"
|
|
235
237
|
"scaled"
|
|
236
238
|
else
|
|
237
239
|
"noQuantity"
|
data/lib/cooklang/ingredient.rb
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Cooklang
|
|
4
4
|
class Ingredient
|
|
5
|
-
attr_reader :name, :quantity, :unit, :notes
|
|
5
|
+
attr_reader :name, :quantity, :unit, :notes, :fixed
|
|
6
6
|
|
|
7
|
-
def initialize(name:, quantity: nil, unit: nil, notes: nil)
|
|
7
|
+
def initialize(name:, quantity: nil, unit: nil, notes: nil, fixed: false)
|
|
8
8
|
@name = name.to_s.freeze
|
|
9
9
|
@quantity = quantity
|
|
10
10
|
@unit = unit&.to_s&.freeze
|
|
11
11
|
@notes = notes&.to_s&.freeze
|
|
12
|
+
@fixed = fixed
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def to_s
|
|
@@ -24,7 +25,8 @@ module Cooklang
|
|
|
24
25
|
name: @name,
|
|
25
26
|
quantity: @quantity,
|
|
26
27
|
unit: @unit,
|
|
27
|
-
notes: @notes
|
|
28
|
+
notes: @notes,
|
|
29
|
+
fixed: @fixed
|
|
28
30
|
}.compact
|
|
29
31
|
end
|
|
30
32
|
|
|
@@ -34,7 +36,8 @@ module Cooklang
|
|
|
34
36
|
name == other.name &&
|
|
35
37
|
quantity == other.quantity &&
|
|
36
38
|
unit == other.unit &&
|
|
37
|
-
notes == other.notes
|
|
39
|
+
notes == other.notes &&
|
|
40
|
+
fixed == other.fixed
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
def eql?(other)
|
|
@@ -42,7 +45,7 @@ module Cooklang
|
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def hash
|
|
45
|
-
[name, quantity, unit, notes].hash
|
|
48
|
+
[name, quantity, unit, notes, fixed].hash
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def has_quantity?
|
|
@@ -56,5 +59,9 @@ module Cooklang
|
|
|
56
59
|
def has_notes?
|
|
57
60
|
!@notes.nil?
|
|
58
61
|
end
|
|
62
|
+
|
|
63
|
+
def fixed?
|
|
64
|
+
@fixed
|
|
65
|
+
end
|
|
59
66
|
end
|
|
60
67
|
end
|
data/lib/cooklang/lexer.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Cooklang
|
|
|
23
23
|
comment_block_start: "[-",
|
|
24
24
|
comment_block_end: "-]",
|
|
25
25
|
metadata_marker: ">>",
|
|
26
|
-
|
|
26
|
+
equals: "=",
|
|
27
27
|
note_marker: ">",
|
|
28
28
|
newline: "\n",
|
|
29
29
|
yaml_delimiter: "---"
|
|
@@ -50,7 +50,7 @@ module Cooklang
|
|
|
50
50
|
elsif match_comment_block
|
|
51
51
|
elsif match_comment_line
|
|
52
52
|
elsif match_metadata_marker
|
|
53
|
-
elsif
|
|
53
|
+
elsif match_equals
|
|
54
54
|
elsif match_note_marker
|
|
55
55
|
elsif match_special_chars
|
|
56
56
|
elsif match_newline
|
|
@@ -228,13 +228,13 @@ module Cooklang
|
|
|
228
228
|
end
|
|
229
229
|
end
|
|
230
230
|
|
|
231
|
-
def
|
|
231
|
+
def match_equals
|
|
232
232
|
if @scanner.check(/=+/)
|
|
233
233
|
position = current_position
|
|
234
234
|
line = current_line
|
|
235
235
|
column = current_column
|
|
236
236
|
value = @scanner.scan(/=+/)
|
|
237
|
-
@tokens << Token.new(:
|
|
237
|
+
@tokens << Token.new(:equals, value, position, line, column)
|
|
238
238
|
advance_position(value)
|
|
239
239
|
true
|
|
240
240
|
else
|
|
@@ -35,7 +35,7 @@ module Cooklang
|
|
|
35
35
|
name = extract_name_until_brace(brace_index)
|
|
36
36
|
@stream.consume(:open_brace) # Skip open brace
|
|
37
37
|
|
|
38
|
-
quantity, unit = extract_quantity_and_unit
|
|
38
|
+
quantity, unit, fixed = extract_quantity_and_unit
|
|
39
39
|
@stream.consume(:close_brace) # Skip close brace
|
|
40
40
|
|
|
41
41
|
notes = extract_notes
|
|
@@ -44,7 +44,7 @@ module Cooklang
|
|
|
44
44
|
quantity = "some" if (quantity.nil? || quantity == "") && (unit.nil? || unit == "")
|
|
45
45
|
unit = nil if unit == ""
|
|
46
46
|
|
|
47
|
-
Ingredient.new(name: name, quantity: quantity, unit: unit, notes: notes)
|
|
47
|
+
Ingredient.new(name: name, quantity: quantity, unit: unit, notes: notes, fixed: fixed)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def parse_simple_ingredient
|
|
@@ -85,8 +85,14 @@ module Cooklang
|
|
|
85
85
|
def extract_quantity_and_unit
|
|
86
86
|
quantity = nil
|
|
87
87
|
unit = nil
|
|
88
|
+
fixed = false
|
|
88
89
|
text_parts = []
|
|
89
90
|
|
|
91
|
+
if @stream.current&.type == :equals
|
|
92
|
+
fixed = true
|
|
93
|
+
@stream.consume
|
|
94
|
+
end
|
|
95
|
+
|
|
90
96
|
while !@stream.eof? && @stream.current.type != :close_brace
|
|
91
97
|
case @stream.current.type
|
|
92
98
|
when :percent
|
|
@@ -106,6 +112,7 @@ module Cooklang
|
|
|
106
112
|
|
|
107
113
|
if !text_parts.empty?
|
|
108
114
|
combined_text = text_parts.join.strip
|
|
115
|
+
|
|
109
116
|
if quantity.nil?
|
|
110
117
|
# Try to parse as numeric quantity first, fallback to string if not purely numeric
|
|
111
118
|
if combined_text.match?(/^\d+$/)
|
|
@@ -124,7 +131,7 @@ module Cooklang
|
|
|
124
131
|
end
|
|
125
132
|
end
|
|
126
133
|
|
|
127
|
-
[quantity, unit]
|
|
134
|
+
[quantity, unit, fixed]
|
|
128
135
|
end
|
|
129
136
|
|
|
130
137
|
def parse_quantity_value(text)
|
|
@@ -96,7 +96,7 @@ module Cooklang
|
|
|
96
96
|
when :open_brace, :close_brace, :open_paren, :close_paren, :percent
|
|
97
97
|
builder.add_text(token.value)
|
|
98
98
|
stream.consume
|
|
99
|
-
when :
|
|
99
|
+
when :equals # section marker (= or ==)
|
|
100
100
|
process_section_with_stream(stream, builder)
|
|
101
101
|
else
|
|
102
102
|
stream.consume
|
data/lib/cooklang/version.rb
CHANGED
|
@@ -60,4 +60,16 @@ RSpec.describe Cooklang::Formatters::Hash do
|
|
|
60
60
|
expect(step_numbers).to eq((1..step_numbers.size).to_a)
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
|
+
|
|
64
|
+
describe "fixed quantities" do
|
|
65
|
+
let(:recipe) { Cooklang.parse("Add @salt{=1%tsp} and @flour{500%g} and @pepper.") }
|
|
66
|
+
let(:result) { described_class.new(recipe).generate }
|
|
67
|
+
|
|
68
|
+
it "marks fixed ingredients as 'fixed' in data section" do
|
|
69
|
+
ingredient_data = result[:data][:ingredients]
|
|
70
|
+
|
|
71
|
+
# salt is fixed, flour is scaled, pepper has no quantity
|
|
72
|
+
expect(ingredient_data).to eq(%w[fixed scaled noQuantity])
|
|
73
|
+
end
|
|
74
|
+
end
|
|
63
75
|
end
|
data/spec/lexer_spec.rb
CHANGED
|
@@ -146,12 +146,12 @@ RSpec.describe Cooklang::Lexer do
|
|
|
146
146
|
end
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
context "with section markers" do
|
|
149
|
+
context "with equals (section markers)" do
|
|
150
150
|
it "tokenizes single equals" do
|
|
151
151
|
lexer = described_class.new("= Dough")
|
|
152
152
|
tokens = lexer.tokenize
|
|
153
153
|
|
|
154
|
-
expect(tokens.map(&:type)).to eq(%i[
|
|
154
|
+
expect(tokens.map(&:type)).to eq(%i[equals text])
|
|
155
155
|
expect(tokens.map(&:value)).to eq(["=", " Dough"])
|
|
156
156
|
end
|
|
157
157
|
|
|
@@ -159,7 +159,7 @@ RSpec.describe Cooklang::Lexer do
|
|
|
159
159
|
lexer = described_class.new("== Filling ==")
|
|
160
160
|
tokens = lexer.tokenize
|
|
161
161
|
|
|
162
|
-
expect(tokens.map(&:type)).to eq(%i[
|
|
162
|
+
expect(tokens.map(&:type)).to eq(%i[equals text equals])
|
|
163
163
|
expect(tokens.map(&:value)).to eq(["==", " Filling ", "=="])
|
|
164
164
|
end
|
|
165
165
|
|
|
@@ -167,7 +167,7 @@ RSpec.describe Cooklang::Lexer do
|
|
|
167
167
|
lexer = described_class.new("=== Section ===")
|
|
168
168
|
tokens = lexer.tokenize
|
|
169
169
|
|
|
170
|
-
expect(tokens.map(&:type)).to eq(%i[
|
|
170
|
+
expect(tokens.map(&:type)).to eq(%i[equals text equals])
|
|
171
171
|
expect(tokens.map(&:value)).to eq(["===", " Section ", "==="])
|
|
172
172
|
end
|
|
173
173
|
end
|
|
@@ -47,6 +47,20 @@ RSpec.describe Cooklang::Ingredient do
|
|
|
47
47
|
expect(ingredient.notes).to eq("sifted")
|
|
48
48
|
expect(ingredient.notes).to be_frozen
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
it "defaults fixed to false" do
|
|
52
|
+
ingredient = described_class.new(name: "salt")
|
|
53
|
+
|
|
54
|
+
expect(ingredient.fixed).to be false
|
|
55
|
+
expect(ingredient.fixed?).to be false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "accepts fixed parameter" do
|
|
59
|
+
ingredient = described_class.new(name: "salt", quantity: 1, unit: "tsp", fixed: true)
|
|
60
|
+
|
|
61
|
+
expect(ingredient.fixed).to be true
|
|
62
|
+
expect(ingredient.fixed?).to be true
|
|
63
|
+
end
|
|
50
64
|
end
|
|
51
65
|
|
|
52
66
|
describe "#to_s" do
|
|
@@ -94,16 +108,34 @@ RSpec.describe Cooklang::Ingredient do
|
|
|
94
108
|
name: "flour",
|
|
95
109
|
quantity: 125,
|
|
96
110
|
unit: "g",
|
|
97
|
-
notes: "sifted"
|
|
111
|
+
notes: "sifted",
|
|
112
|
+
fixed: false
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
expect(ingredient.to_h).to eq(expected)
|
|
101
116
|
end
|
|
102
117
|
|
|
103
|
-
it "omits nil attributes" do
|
|
118
|
+
it "omits nil attributes but includes fixed" do
|
|
104
119
|
ingredient = described_class.new(name: "salt")
|
|
105
120
|
|
|
106
|
-
expect(ingredient.to_h).to eq({ name: "salt" })
|
|
121
|
+
expect(ingredient.to_h).to eq({ name: "salt", fixed: false })
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "includes fixed when true" do
|
|
125
|
+
ingredient = described_class.new(name: "salt", quantity: 1, unit: "tsp", fixed: true)
|
|
126
|
+
|
|
127
|
+
expect(ingredient.to_h).to eq({
|
|
128
|
+
name: "salt",
|
|
129
|
+
quantity: 1,
|
|
130
|
+
unit: "tsp",
|
|
131
|
+
fixed: true
|
|
132
|
+
})
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "includes fixed as false when not fixed" do
|
|
136
|
+
ingredient = described_class.new(name: "salt", quantity: 1, unit: "tsp", fixed: false)
|
|
137
|
+
|
|
138
|
+
expect(ingredient.to_h[:fixed]).to be false
|
|
107
139
|
end
|
|
108
140
|
end
|
|
109
141
|
|
|
@@ -129,6 +161,13 @@ RSpec.describe Cooklang::Ingredient do
|
|
|
129
161
|
expect(ingredient1).not_to eq(ingredient2)
|
|
130
162
|
end
|
|
131
163
|
|
|
164
|
+
it "returns false for ingredients with different fixed values" do
|
|
165
|
+
ingredient1 = described_class.new(name: "salt", quantity: 1, fixed: true)
|
|
166
|
+
ingredient2 = described_class.new(name: "salt", quantity: 1, fixed: false)
|
|
167
|
+
|
|
168
|
+
expect(ingredient1).not_to eq(ingredient2)
|
|
169
|
+
end
|
|
170
|
+
|
|
132
171
|
it "returns false for non-Ingredient objects" do
|
|
133
172
|
ingredient = described_class.new(name: "flour")
|
|
134
173
|
|
|
@@ -172,6 +211,23 @@ RSpec.describe Cooklang::Ingredient do
|
|
|
172
211
|
expect(ingredient).not_to have_notes
|
|
173
212
|
end
|
|
174
213
|
end
|
|
214
|
+
|
|
215
|
+
describe "#fixed?" do
|
|
216
|
+
it "returns true when fixed is true" do
|
|
217
|
+
ingredient = described_class.new(name: "salt", quantity: 1, fixed: true)
|
|
218
|
+
expect(ingredient).to be_fixed
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "returns false when fixed is false" do
|
|
222
|
+
ingredient = described_class.new(name: "salt", quantity: 1, fixed: false)
|
|
223
|
+
expect(ingredient).not_to be_fixed
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "returns false by default" do
|
|
227
|
+
ingredient = described_class.new(name: "salt")
|
|
228
|
+
expect(ingredient).not_to be_fixed
|
|
229
|
+
end
|
|
230
|
+
end
|
|
175
231
|
end
|
|
176
232
|
|
|
177
233
|
describe "#hash" do
|
data/spec/parser_spec.rb
CHANGED
|
@@ -96,6 +96,73 @@ RSpec.describe Cooklang::Parser do
|
|
|
96
96
|
end
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
context "with fixed quantities" do
|
|
100
|
+
it "parses fixed quantity with unit" do
|
|
101
|
+
recipe = parser.parse("Season with @salt{=1%tsp}.")
|
|
102
|
+
|
|
103
|
+
expect(recipe.ingredients.size).to eq(1)
|
|
104
|
+
ingredient = recipe.ingredients.first
|
|
105
|
+
expect(ingredient.name).to eq("salt")
|
|
106
|
+
expect(ingredient.quantity).to eq(1)
|
|
107
|
+
expect(ingredient.unit).to eq("tsp")
|
|
108
|
+
expect(ingredient).to be_fixed
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "parses fixed quantity without unit" do
|
|
112
|
+
recipe = parser.parse("Add @eggs{=3} exactly.")
|
|
113
|
+
|
|
114
|
+
expect(recipe.ingredients.size).to eq(1)
|
|
115
|
+
ingredient = recipe.ingredients.first
|
|
116
|
+
expect(ingredient.name).to eq("eggs")
|
|
117
|
+
expect(ingredient.quantity).to eq(3)
|
|
118
|
+
expect(ingredient).to be_fixed
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "parses fixed quantity with space after equals" do
|
|
122
|
+
recipe = parser.parse("Add @salt{= 2%tsp}.")
|
|
123
|
+
|
|
124
|
+
ingredient = recipe.ingredients.first
|
|
125
|
+
expect(ingredient.quantity).to eq(2)
|
|
126
|
+
expect(ingredient.unit).to eq("tsp")
|
|
127
|
+
expect(ingredient).to be_fixed
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "parses fixed quantity with text value" do
|
|
131
|
+
recipe = parser.parse("Add @salt{=some%pinch}.")
|
|
132
|
+
|
|
133
|
+
ingredient = recipe.ingredients.first
|
|
134
|
+
expect(ingredient.quantity).to eq("some")
|
|
135
|
+
expect(ingredient.unit).to eq("pinch")
|
|
136
|
+
expect(ingredient).to be_fixed
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "parses fixed with no quantity as fixed with 'some'" do
|
|
140
|
+
recipe = parser.parse("Add @salt{=}.")
|
|
141
|
+
|
|
142
|
+
ingredient = recipe.ingredients.first
|
|
143
|
+
expect(ingredient.quantity).to eq("some")
|
|
144
|
+
expect(ingredient).to be_fixed
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "parses regular quantity as not fixed" do
|
|
148
|
+
recipe = parser.parse("Use @flour{500%g}.")
|
|
149
|
+
|
|
150
|
+
ingredient = recipe.ingredients.first
|
|
151
|
+
expect(ingredient).not_to be_fixed
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "parses mixed fixed and regular ingredients" do
|
|
155
|
+
recipe = parser.parse("Add @salt{=1%tsp} and @flour{500%g}.")
|
|
156
|
+
|
|
157
|
+
expect(recipe.ingredients.size).to eq(2)
|
|
158
|
+
salt = recipe.ingredients.find { |i| i.name == "salt" }
|
|
159
|
+
flour = recipe.ingredients.find { |i| i.name == "flour" }
|
|
160
|
+
|
|
161
|
+
expect(salt).to be_fixed
|
|
162
|
+
expect(flour).not_to be_fixed
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
99
166
|
context "with cookware" do
|
|
100
167
|
it "parses simple cookware" do
|
|
101
168
|
recipe = parser.parse("Heat the #pan over medium heat.")
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cooklang
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Brooks
|
|
@@ -197,7 +197,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
197
197
|
- !ruby/object:Gem::Version
|
|
198
198
|
version: '0'
|
|
199
199
|
requirements: []
|
|
200
|
-
rubygems_version: 3.7.
|
|
200
|
+
rubygems_version: 3.7.2
|
|
201
201
|
specification_version: 4
|
|
202
202
|
summary: A Ruby parser for the Cooklang recipe markup language.
|
|
203
203
|
test_files:
|