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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa4eed0618449146de288cbfa4b51b05d7ac994fca5e3aca0161c6cfe90d2551
4
- data.tar.gz: 45c683a6a2daea2da5d48116cf91181532370df0b1b2e5de0d1b72a749f2e245
3
+ metadata.gz: 31ab63d47990eb2eaa6eb3dd09af9ba81d760cfbcfde937467f7060282195409
4
+ data.tar.gz: 2fb316c77fcfaabdc679943997cfa68607d0dbf61599def411743ff53be8f0a6
5
5
  SHA512:
6
- metadata.gz: b4cfc4bad69e170f6fff3b5aef1d35b31ba72232b1c9579129f1b3ec1e5f1174af9501a3455be440da077a7138c3af1b822a6f11f232cc6c420621cea8d06552
7
- data.tar.gz: 6d84e7c23687b56076c2b9aef18e1f801dbf471ce2fd3e5f216f62016c33656152c7634d3d6d0fa95c9b15181d6f1b05196c225301da0c5ec184a1e136375e73
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.quantity && ingredient.quantity != "some"
234
+ if ingredient.fixed?
235
+ "fixed"
236
+ elsif ingredient.quantity && ingredient.quantity != "some"
235
237
  "scaled"
236
238
  else
237
239
  "noQuantity"
@@ -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
@@ -23,7 +23,7 @@ module Cooklang
23
23
  comment_block_start: "[-",
24
24
  comment_block_end: "-]",
25
25
  metadata_marker: ">>",
26
- section_marker: "=",
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 match_section_marker
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 match_section_marker
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(:section_marker, value, position, line, column)
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 :section_marker
99
+ when :equals # section marker (= or ==)
100
100
  process_section_with_stream(stream, builder)
101
101
  else
102
102
  stream.consume
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cooklang
4
- VERSION = "1.0.4"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -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[section_marker text])
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[section_marker text section_marker])
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[section_marker text section_marker])
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
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.1
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: