cooklang 1.0.0 → 1.0.1

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +35 -0
  3. data/.gitignore +12 -0
  4. data/.qlty/.gitignore +7 -0
  5. data/.qlty/configs/.yamllint.yaml +21 -0
  6. data/.qlty/qlty.toml +101 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +340 -0
  9. data/Gemfile +6 -0
  10. data/Gemfile.lock +84 -0
  11. data/README.md +10 -5
  12. data/Rakefile +12 -0
  13. data/cooklang.gemspec +35 -0
  14. data/lib/cooklang/builders/recipe_builder.rb +64 -0
  15. data/lib/cooklang/builders/step_builder.rb +76 -0
  16. data/lib/cooklang/lexer.rb +5 -14
  17. data/lib/cooklang/parser.rb +24 -653
  18. data/lib/cooklang/parsers/cookware_parser.rb +133 -0
  19. data/lib/cooklang/parsers/ingredient_parser.rb +179 -0
  20. data/lib/cooklang/parsers/timer_parser.rb +135 -0
  21. data/lib/cooklang/processors/element_parser.rb +45 -0
  22. data/lib/cooklang/processors/metadata_processor.rb +129 -0
  23. data/lib/cooklang/processors/step_processor.rb +208 -0
  24. data/lib/cooklang/processors/token_processor.rb +104 -0
  25. data/lib/cooklang/recipe.rb +25 -15
  26. data/lib/cooklang/step.rb +12 -2
  27. data/lib/cooklang/timer.rb +3 -1
  28. data/lib/cooklang/token_stream.rb +130 -0
  29. data/lib/cooklang/version.rb +1 -1
  30. data/spec/comprehensive_spec.rb +179 -0
  31. data/spec/cooklang_spec.rb +38 -0
  32. data/spec/fixtures/canonical.yaml +837 -0
  33. data/spec/formatters/text_spec.rb +189 -0
  34. data/spec/integration/canonical_spec.rb +211 -0
  35. data/spec/lexer_spec.rb +357 -0
  36. data/spec/models/cookware_spec.rb +116 -0
  37. data/spec/models/ingredient_spec.rb +192 -0
  38. data/spec/models/metadata_spec.rb +241 -0
  39. data/spec/models/note_spec.rb +65 -0
  40. data/spec/models/recipe_spec.rb +171 -0
  41. data/spec/models/section_spec.rb +65 -0
  42. data/spec/models/step_spec.rb +236 -0
  43. data/spec/models/timer_spec.rb +173 -0
  44. data/spec/parser_spec.rb +398 -0
  45. data/spec/spec_helper.rb +23 -0
  46. data/spec/token_stream_spec.rb +278 -0
  47. metadata +162 -6
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Parser do
6
+ let(:parser) { described_class.new }
7
+
8
+ describe "#parse" do
9
+ context "with simple ingredients" do
10
+ it "parses single word ingredients" do
11
+ recipe = parser.parse("Add @salt to taste.")
12
+
13
+ expect(recipe.ingredients.size).to eq(1)
14
+ ingredient = recipe.ingredients.first
15
+ expect(ingredient.name).to eq("salt")
16
+ expect(ingredient.quantity).to eq("some") # Default quantity per canonical tests
17
+ expect(ingredient.unit).to be_nil
18
+ expect(ingredient.notes).to be_nil
19
+ end
20
+
21
+ it "parses multi-word ingredients with braces" do
22
+ recipe = parser.parse("Add @ground pepper{} to taste.")
23
+
24
+ expect(recipe.ingredients.size).to eq(1)
25
+ ingredient = recipe.ingredients.first
26
+ expect(ingredient.name).to eq("ground pepper")
27
+ end
28
+
29
+ it "parses multi-word ingredients without braces (first word only)" do
30
+ recipe = parser.parse("Add @ground pepper to taste.")
31
+
32
+ expect(recipe.ingredients.size).to eq(1)
33
+ ingredient = recipe.ingredients.first
34
+ expect(ingredient.name).to eq("ground")
35
+ end
36
+
37
+ it "parses ingredients with quantities" do
38
+ recipe = parser.parse("Use @flour{125%g} for the batter.")
39
+
40
+ expect(recipe.ingredients.size).to eq(1)
41
+ ingredient = recipe.ingredients.first
42
+ expect(ingredient.name).to eq("flour")
43
+ expect(ingredient.quantity).to eq(125)
44
+ expect(ingredient.unit).to eq("g")
45
+ end
46
+
47
+ it "parses ingredients with quantities but no unit" do
48
+ recipe = parser.parse("Crack @eggs{3} into bowl.")
49
+
50
+ expect(recipe.ingredients.size).to eq(1)
51
+ ingredient = recipe.ingredients.first
52
+ expect(ingredient.name).to eq("eggs")
53
+ expect(ingredient.quantity).to eq(3)
54
+ expect(ingredient.unit).to be_nil
55
+ end
56
+
57
+ it "parses ingredients with notes" do
58
+ recipe = parser.parse("Dice @onion{1}(large) finely.")
59
+
60
+ expect(recipe.ingredients.size).to eq(1)
61
+ ingredient = recipe.ingredients.first
62
+ expect(ingredient.name).to eq("onion")
63
+ expect(ingredient.quantity).to eq(1)
64
+ expect(ingredient.unit).to be_nil
65
+ expect(ingredient.notes).to eq("large")
66
+ end
67
+
68
+ it "parses ingredients with quantity, unit, and notes" do
69
+ recipe = parser.parse("Add @butter{2%tbsp}(melted) to pan.")
70
+
71
+ expect(recipe.ingredients.size).to eq(1)
72
+ ingredient = recipe.ingredients.first
73
+ expect(ingredient.name).to eq("butter")
74
+ expect(ingredient.quantity).to eq(2)
75
+ expect(ingredient.unit).to eq("tbsp")
76
+ expect(ingredient.notes).to eq("melted")
77
+ end
78
+
79
+ it "parses ingredients with string quantities" do
80
+ recipe = parser.parse("Add @salt{some} to taste.")
81
+
82
+ expect(recipe.ingredients.size).to eq(1)
83
+ ingredient = recipe.ingredients.first
84
+ expect(ingredient.name).to eq("salt")
85
+ expect(ingredient.quantity).to eq("some")
86
+ end
87
+
88
+ it "parses ingredients with decimal quantities" do
89
+ recipe = parser.parse("Use @flour{1.5%cups} for batter.")
90
+
91
+ expect(recipe.ingredients.size).to eq(1)
92
+ ingredient = recipe.ingredients.first
93
+ expect(ingredient.name).to eq("flour")
94
+ expect(ingredient.quantity).to eq(1.5)
95
+ expect(ingredient.unit).to eq("cups")
96
+ end
97
+ end
98
+
99
+ context "with cookware" do
100
+ it "parses simple cookware" do
101
+ recipe = parser.parse("Heat the #pan over medium heat.")
102
+
103
+ expect(recipe.cookware.size).to eq(1)
104
+ cookware = recipe.cookware.first
105
+ expect(cookware.name).to eq("pan")
106
+ expect(cookware.quantity).to eq(1) # Default quantity per canonical tests
107
+ end
108
+
109
+ it "parses multi-word cookware with braces" do
110
+ recipe = parser.parse("Use a #large skillet{} for cooking.")
111
+
112
+ expect(recipe.cookware.size).to eq(1)
113
+ cookware = recipe.cookware.first
114
+ expect(cookware.name).to eq("large skillet")
115
+ end
116
+
117
+ it "parses multi-word cookware without braces (first word only)" do
118
+ recipe = parser.parse("Use a #large skillet for cooking.")
119
+
120
+ expect(recipe.cookware.size).to eq(1)
121
+ cookware = recipe.cookware.first
122
+ expect(cookware.name).to eq("large")
123
+ end
124
+
125
+ it "parses cookware with quantities" do
126
+ recipe = parser.parse("Prepare #baking sheet{2} with parchment.")
127
+
128
+ expect(recipe.cookware.size).to eq(1)
129
+ cookware = recipe.cookware.first
130
+ expect(cookware.name).to eq("baking sheet")
131
+ expect(cookware.quantity).to eq(2)
132
+ end
133
+
134
+ it "parses cookware with string quantities" do
135
+ recipe = parser.parse("Use #bowls{several} for prep.")
136
+
137
+ expect(recipe.cookware.size).to eq(1)
138
+ cookware = recipe.cookware.first
139
+ expect(cookware.name).to eq("bowls")
140
+ expect(cookware.quantity).to eq("several")
141
+ end
142
+ end
143
+
144
+ context "with timers" do
145
+ it "parses anonymous timers" do
146
+ recipe = parser.parse("Cook for ~{10%minutes}.")
147
+
148
+ expect(recipe.timers.size).to eq(1)
149
+ timer = recipe.timers.first
150
+ expect(timer.name).to be_nil
151
+ expect(timer.duration).to eq(10)
152
+ expect(timer.unit).to eq("minutes")
153
+ end
154
+
155
+ it "parses named timers" do
156
+ recipe = parser.parse("Bake ~oven{25%minutes} until golden.")
157
+
158
+ expect(recipe.timers.size).to eq(1)
159
+ timer = recipe.timers.first
160
+ expect(timer.name).to eq("oven")
161
+ expect(timer.duration).to eq(25)
162
+ expect(timer.unit).to eq("minutes")
163
+ end
164
+
165
+ it "parses timers with decimal durations" do
166
+ recipe = parser.parse("Heat for ~{1.5%hours}.")
167
+
168
+ expect(recipe.timers.size).to eq(1)
169
+ timer = recipe.timers.first
170
+ expect(timer.duration).to eq(1.5)
171
+ expect(timer.unit).to eq("hours")
172
+ end
173
+ end
174
+
175
+ context "with comments" do
176
+ it "strips line comments" do
177
+ recipe = parser.parse("Add @salt -- this is a comment\nto taste.")
178
+
179
+ expect(recipe.steps.size).to eq(1)
180
+ step_text = recipe.steps.first.to_text
181
+ # Comments preserve newlines for canonical compatibility
182
+ expect(step_text).to eq("Add salt \nto taste.")
183
+ end
184
+
185
+ it "strips block comments" do
186
+ recipe = parser.parse("Add @salt [- this is a block comment -] to taste.")
187
+
188
+ expect(recipe.steps.size).to eq(1)
189
+ step_text = recipe.steps.first.to_text
190
+ expect(step_text).to eq("Add salt to taste.")
191
+ end
192
+ end
193
+
194
+ context "with metadata" do
195
+ it "parses YAML front matter" do
196
+ input = <<~RECIPE
197
+ ---
198
+ title: Pancakes
199
+ servings: 4
200
+ prep_time: 10
201
+ ---
202
+
203
+ Mix @flour{1%cup} with @milk{1%cup}.
204
+ RECIPE
205
+
206
+ recipe = parser.parse(input)
207
+
208
+ expect(recipe.metadata["title"]).to eq("Pancakes")
209
+ expect(recipe.metadata["servings"]).to eq(4)
210
+ expect(recipe.metadata["prep_time"]).to eq(10)
211
+ end
212
+
213
+ it "parses inline metadata" do
214
+ input = <<~RECIPE
215
+ >> title: Quick Eggs
216
+ >> servings: 2
217
+
218
+ Crack @eggs{2} into #pan.
219
+ RECIPE
220
+
221
+ recipe = parser.parse(input)
222
+
223
+ expect(recipe.metadata["title"]).to eq("Quick Eggs")
224
+ expect(recipe.metadata["servings"]).to eq(2)
225
+ end
226
+ end
227
+
228
+ context "with multiple steps" do
229
+ it "splits steps by blank lines" do
230
+ input = <<~RECIPE
231
+ Mix @flour{1%cup} with @milk{1%cup}.
232
+
233
+ Heat #pan over medium heat.
234
+
235
+ Pour batter into #pan and cook ~{3%minutes}.
236
+ RECIPE
237
+
238
+ recipe = parser.parse(input)
239
+
240
+ expect(recipe.steps.size).to eq(3)
241
+ expect(recipe.steps[0].to_text).to eq("Mix flour with milk.")
242
+ expect(recipe.steps[1].to_text).to eq("Heat pan over medium heat.")
243
+ expect(recipe.steps[2].to_text).to eq("Pour batter into pan and cook for 3 minutes.")
244
+ end
245
+ end
246
+
247
+ context "with mixed elements" do
248
+ it "parses a complex recipe" do
249
+ input = <<~RECIPE
250
+ ---
251
+ title: Scrambled Eggs
252
+ servings: 2
253
+ ---
254
+
255
+ Heat #pan{1} over medium heat.
256
+
257
+ Crack @eggs{3} into #bowl and whisk.
258
+
259
+ Add @butter{1%tbsp}(melted) to #pan.
260
+
261
+ Pour eggs into #pan and cook ~{2-3%minutes}, stirring constantly.
262
+ RECIPE
263
+
264
+ recipe = parser.parse(input)
265
+
266
+ # Check metadata
267
+ expect(recipe.metadata["title"]).to eq("Scrambled Eggs")
268
+ expect(recipe.metadata["servings"]).to eq(2)
269
+
270
+ # Check ingredients
271
+ expect(recipe.ingredients.size).to eq(2)
272
+ eggs = recipe.ingredients.find { |i| i.name == "eggs" }
273
+ expect(eggs.quantity).to eq(3)
274
+
275
+ butter = recipe.ingredients.find { |i| i.name == "butter" }
276
+ expect(butter.quantity).to eq(1)
277
+ expect(butter.unit).to eq("tbsp")
278
+ expect(butter.notes).to eq("melted")
279
+
280
+ # Check cookware
281
+ expect(recipe.cookware.size).to eq(2)
282
+ pan = recipe.cookware.find { |c| c.name == "pan" }
283
+ expect(pan.quantity).to eq(1)
284
+
285
+ bowl = recipe.cookware.find { |c| c.name == "bowl" }
286
+ expect(bowl).not_to be_nil
287
+
288
+ # Check timers
289
+ expect(recipe.timers.size).to eq(1)
290
+ timer = recipe.timers.first
291
+ expect(timer.duration).to eq("2-3")
292
+ expect(timer.unit).to eq("minutes")
293
+
294
+ # Check steps
295
+ expect(recipe.steps.size).to eq(4)
296
+ end
297
+ end
298
+
299
+ context "with edge cases" do
300
+ it "handles empty input" do
301
+ recipe = parser.parse("")
302
+
303
+ expect(recipe.ingredients).to be_empty
304
+ expect(recipe.cookware).to be_empty
305
+ expect(recipe.timers).to be_empty
306
+ expect(recipe.steps).to be_empty
307
+ expect(recipe.metadata).to be_empty
308
+ end
309
+
310
+ it "handles whitespace-only input" do
311
+ recipe = parser.parse(" \n\n \n ")
312
+
313
+ expect(recipe.ingredients).to be_empty
314
+ expect(recipe.cookware).to be_empty
315
+ expect(recipe.timers).to be_empty
316
+ expect(recipe.steps).to be_empty
317
+ end
318
+
319
+ it "deduplicates identical ingredients" do
320
+ recipe = parser.parse("Add @salt. Then add more @salt.")
321
+
322
+ expect(recipe.ingredients.size).to eq(1)
323
+ expect(recipe.ingredients.first.name).to eq("salt")
324
+ end
325
+
326
+ it "keeps different quantities as separate ingredients" do
327
+ recipe = parser.parse("Add @salt{1%tsp}. Then add @salt{2%tsp}.")
328
+
329
+ expect(recipe.ingredients.size).to eq(2)
330
+ expect(recipe.ingredients.map(&:quantity)).to contain_exactly(1, 2)
331
+ end
332
+ end
333
+
334
+ context "with step segments" do
335
+ it "preserves text and ingredient references in steps" do
336
+ recipe = parser.parse("Mix @flour{1%cup} with @milk.")
337
+
338
+ step = recipe.steps.first
339
+ expect(step.segments.size).to eq(5)
340
+ expect(step.segments[0]).to eq("Mix ")
341
+ expect(step.segments[1]).to be_a(Cooklang::Ingredient)
342
+ expect(step.segments[2]).to eq(" with ")
343
+ expect(step.segments[3]).to be_a(Cooklang::Ingredient)
344
+ expect(step.segments[4]).to eq(".")
345
+ end
346
+
347
+ it "includes cookware and timers in step segments" do
348
+ recipe = parser.parse("Cook in #pan for ~{5%minutes}.")
349
+
350
+ step = recipe.steps.first
351
+ expect(step.segments).to include(an_instance_of(Cooklang::Cookware))
352
+ expect(step.segments).to include(an_instance_of(Cooklang::Timer))
353
+ end
354
+ end
355
+
356
+ context "with edge cases" do
357
+ it "handles timer with invalid duration format" do
358
+ input = "Cook ~{invalid_duration}"
359
+ recipe = parser.parse(input)
360
+
361
+ expect(recipe.timers.size).to eq(1)
362
+ timer = recipe.timers.first
363
+ expect(timer.duration).to eq("invalid_duration")
364
+ expect(timer.unit).to be_nil
365
+ end
366
+
367
+ it "handles timer with number-only duration" do
368
+ input = "Cook ~{30}"
369
+ recipe = parser.parse(input)
370
+
371
+ expect(recipe.timers.size).to eq(1)
372
+ timer = recipe.timers.first
373
+ expect(timer.duration).to eq(30)
374
+ expect(timer.unit).to be_nil
375
+ end
376
+
377
+ it "handles timer with decimal duration" do
378
+ input = "Cook ~{2.5}"
379
+ recipe = parser.parse(input)
380
+
381
+ expect(recipe.timers.size).to eq(1)
382
+ timer = recipe.timers.first
383
+ expect(timer.duration).to eq(2.5)
384
+ expect(timer.unit).to be_nil
385
+ end
386
+
387
+ it "handles ingredient with invalid quantity format" do
388
+ input = "Add @salt{invalid_amount}"
389
+ recipe = parser.parse(input)
390
+
391
+ expect(recipe.ingredients.size).to eq(1)
392
+ ingredient = recipe.ingredients.first
393
+ expect(ingredient.quantity).to eq("invalid_amount")
394
+ expect(ingredient.unit).to be_nil
395
+ end
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "simplecov"
4
+ require "simplecov_json_formatter"
5
+
6
+ SimpleCov.start do
7
+ formatter SimpleCov::Formatter::JSONFormatter
8
+ add_filter "/spec/"
9
+ end
10
+
11
+ require "cooklang"
12
+
13
+ RSpec.configure do |config|
14
+ # Enable flags like --only-failures and --next-failure
15
+ config.example_status_persistence_file_path = ".rspec_status"
16
+
17
+ # Disable RSpec exposing methods globally on `Module` and `main`
18
+ config.disable_monkey_patching!
19
+
20
+ config.expect_with :rspec do |c|
21
+ c.syntax = :expect
22
+ end
23
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "cooklang/token_stream"
5
+ require "cooklang/lexer"
6
+
7
+ RSpec.describe Cooklang::TokenStream do
8
+ let(:tokens) do
9
+ [
10
+ Cooklang::Token.new(:text, "hello", 0, 0, 0),
11
+ Cooklang::Token.new(:ingredient_marker, "@", 0, 5, 5),
12
+ Cooklang::Token.new(:text, "salt", 0, 6, 6),
13
+ Cooklang::Token.new(:open_brace, "{", 0, 10, 10),
14
+ Cooklang::Token.new(:text, "2%g", 0, 11, 11),
15
+ Cooklang::Token.new(:close_brace, "}", 0, 14, 14),
16
+ Cooklang::Token.new(:newline, "\n", 0, 15, 15)
17
+ ]
18
+ end
19
+
20
+ let(:stream) { described_class.new(tokens) }
21
+
22
+ describe "#initialize" do
23
+ it "sets up the token array and position" do
24
+ expect(stream.position).to eq(0)
25
+ expect(stream.size).to eq(7)
26
+ expect(stream.length).to eq(7)
27
+ end
28
+ end
29
+
30
+ describe "#current" do
31
+ it "returns the current token" do
32
+ expect(stream.current.type).to eq(:text)
33
+ expect(stream.current.value).to eq("hello")
34
+ end
35
+
36
+ it "returns nil when at EOF" do
37
+ empty_stream = described_class.new([])
38
+ expect(empty_stream.current).to be_nil
39
+ end
40
+ end
41
+
42
+ describe "#peek" do
43
+ it "returns the next token without advancing" do
44
+ expect(stream.peek.type).to eq(:ingredient_marker)
45
+ expect(stream.position).to eq(0)
46
+ end
47
+
48
+ it "accepts an offset parameter" do
49
+ expect(stream.peek(2).type).to eq(:text)
50
+ expect(stream.peek(2).value).to eq("salt")
51
+ end
52
+
53
+ it "returns nil when peeking beyond EOF" do
54
+ expect(stream.peek(10)).to be_nil
55
+ end
56
+ end
57
+
58
+ describe "#consume" do
59
+ it "returns current token and advances position" do
60
+ token = stream.consume
61
+ expect(token.type).to eq(:text)
62
+ expect(token.value).to eq("hello")
63
+ expect(stream.position).to eq(1)
64
+ end
65
+
66
+ it "accepts expected type parameter" do
67
+ token = stream.consume(:text)
68
+ expect(token.type).to eq(:text)
69
+ expect(stream.position).to eq(1)
70
+ end
71
+
72
+ it "returns nil when expected type does not match" do
73
+ token = stream.consume(:ingredient_marker)
74
+ expect(token).to be_nil
75
+ expect(stream.position).to eq(0)
76
+ end
77
+
78
+ it "returns nil at EOF" do
79
+ empty_stream = described_class.new([])
80
+ expect(empty_stream.consume).to be_nil
81
+ end
82
+ end
83
+
84
+ describe "#eof?" do
85
+ it "returns false when tokens remain" do
86
+ expect(stream.eof?).to be_falsey
87
+ end
88
+
89
+ it "returns true when at end" do
90
+ stream.advance_to(7)
91
+ expect(stream.eof?).to be_truthy
92
+ end
93
+ end
94
+
95
+ describe "#scan" do
96
+ it "consumes token when type matches" do
97
+ token = stream.scan(:text)
98
+ expect(token.value).to eq("hello")
99
+ expect(stream.position).to eq(1)
100
+ end
101
+
102
+ it "returns nil when type does not match" do
103
+ token = stream.scan(:ingredient_marker)
104
+ expect(token).to be_nil
105
+ expect(stream.position).to eq(0)
106
+ end
107
+ end
108
+
109
+ describe "#check" do
110
+ it "returns true when current token type matches" do
111
+ expect(stream.check(:text)).to be_truthy
112
+ end
113
+
114
+ it "returns false when current token type does not match" do
115
+ expect(stream.check(:ingredient_marker)).to be_falsey
116
+ end
117
+ end
118
+
119
+ describe "#skip" do
120
+ it "advances position when type matches" do
121
+ stream.skip(:text)
122
+ expect(stream.position).to eq(1)
123
+ end
124
+
125
+ it "does not advance when type does not match" do
126
+ stream.skip(:ingredient_marker)
127
+ expect(stream.position).to eq(0)
128
+ end
129
+ end
130
+
131
+ describe "#consume_while" do
132
+ it "consumes tokens while condition is true" do
133
+ stream.consume # Move to ingredient marker
134
+ tokens_consumed = stream.consume_while { |token| token.type != :open_brace }
135
+
136
+ expect(tokens_consumed.length).to eq(2)
137
+ expect(tokens_consumed[0].type).to eq(:ingredient_marker)
138
+ expect(tokens_consumed[1].type).to eq(:text)
139
+ expect(stream.current.type).to eq(:open_brace)
140
+ end
141
+ end
142
+
143
+ describe "#consume_until" do
144
+ it "consumes tokens until condition is true" do
145
+ tokens_consumed = stream.consume_until { |token| token.type == :open_brace }
146
+
147
+ expect(tokens_consumed.length).to eq(3)
148
+ expect(stream.current.type).to eq(:open_brace)
149
+ end
150
+ end
151
+
152
+ describe "#find_next" do
153
+ it "finds the index of the next matching token type" do
154
+ index = stream.find_next(:open_brace)
155
+ expect(index).to eq(3)
156
+ end
157
+
158
+ it "returns nil when token type not found" do
159
+ index = stream.find_next(:hyphen)
160
+ expect(index).to be_nil
161
+ end
162
+ end
163
+
164
+ describe "#find_next_matching" do
165
+ it "finds the index of the next token matching a block condition" do
166
+ index = stream.find_next_matching { |token| token.value.include?("g") }
167
+ expect(index).to eq(4)
168
+ end
169
+ end
170
+
171
+ describe "#rest" do
172
+ it "returns remaining tokens from current position" do
173
+ stream.consume # Move to ingredient marker
174
+ remaining = stream.rest
175
+ expect(remaining.length).to eq(6)
176
+ expect(remaining[0].type).to eq(:ingredient_marker)
177
+ end
178
+ end
179
+
180
+ describe "#reset" do
181
+ it "resets position to beginning" do
182
+ stream.consume
183
+ stream.consume
184
+ stream.reset
185
+ expect(stream.position).to eq(0)
186
+ expect(stream.current.type).to eq(:text)
187
+ end
188
+ end
189
+
190
+ describe "#rewind" do
191
+ it "moves position back by specified steps" do
192
+ stream.consume
193
+ stream.consume
194
+ stream.consume
195
+ expect(stream.position).to eq(3)
196
+
197
+ stream.rewind(2)
198
+ expect(stream.position).to eq(1)
199
+ end
200
+
201
+ it "does not go below 0" do
202
+ stream.consume
203
+ stream.rewind(5)
204
+ expect(stream.position).to eq(0)
205
+ end
206
+ end
207
+
208
+ describe "Enumerable methods" do
209
+ it "supports map" do
210
+ types = stream.map(&:type)
211
+ expect(types).to eq([:text, :ingredient_marker, :text, :open_brace, :text, :close_brace, :newline])
212
+ end
213
+
214
+ it "supports find" do
215
+ ingredient_token = stream.find { |token| token.type == :ingredient_marker }
216
+ expect(ingredient_token.type).to eq(:ingredient_marker)
217
+ end
218
+
219
+ it "supports any?" do
220
+ expect(stream.any? { |token| token.type == :open_brace }).to be_truthy
221
+ end
222
+
223
+ it "supports select" do
224
+ text_tokens = stream.select { |token| token.type == :text }
225
+ expect(text_tokens.length).to eq(3)
226
+ end
227
+ end
228
+
229
+ describe "#each_with_lookahead" do
230
+ it "yields current and next token pairs" do
231
+ pairs = []
232
+ stream.each_with_lookahead { |current, next_token| pairs << [current.type, next_token.type] }
233
+
234
+ expect(pairs).to eq([
235
+ [:text, :ingredient_marker],
236
+ [:ingredient_marker, :text],
237
+ [:text, :open_brace],
238
+ [:open_brace, :text],
239
+ [:text, :close_brace],
240
+ [:close_brace, :newline]
241
+ ])
242
+ end
243
+ end
244
+
245
+ describe "#slice_from_current" do
246
+ it "creates a new stream from current position" do
247
+ stream.consume
248
+ stream.consume
249
+ new_stream = stream.slice_from_current
250
+
251
+ expect(new_stream.current.type).to eq(:text)
252
+ expect(new_stream.current.value).to eq("salt")
253
+ expect(new_stream.size).to eq(5)
254
+ end
255
+ end
256
+
257
+ describe "#skip_whitespace" do
258
+ let(:tokens_with_whitespace) do
259
+ [
260
+ Cooklang::Token.new(:text, "start", 0, 1, 1),
261
+ Cooklang::Token.new(:whitespace, " ", 1, 1, 2),
262
+ Cooklang::Token.new(:whitespace, "\t", 2, 1, 3),
263
+ Cooklang::Token.new(:text, "end", 3, 1, 4)
264
+ ]
265
+ end
266
+
267
+ it "consumes whitespace tokens" do
268
+ stream = described_class.new(tokens_with_whitespace)
269
+ stream.consume # Move past "start"
270
+
271
+ expect(stream.current.type).to eq(:whitespace)
272
+ stream.skip_whitespace
273
+
274
+ expect(stream.current.type).to eq(:text)
275
+ expect(stream.current.value).to eq("end")
276
+ end
277
+ end
278
+ end