cooklang 0.1.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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +35 -0
- data/.gitignore +12 -0
- data/.qlty/.gitignore +7 -0
- data/.qlty/configs/.yamllint.yaml +21 -0
- data/.qlty/qlty.toml +101 -0
- data/.rspec +3 -0
- data/.rubocop.yml +289 -75
- data/Gemfile.lock +65 -26
- data/{LICENSE → LICENSE.txt} +6 -6
- data/README.md +106 -12
- data/Rakefile +5 -1
- data/cooklang.gemspec +35 -0
- data/lib/cooklang/builders/recipe_builder.rb +64 -0
- data/lib/cooklang/builders/step_builder.rb +76 -0
- data/lib/cooklang/cookware.rb +43 -0
- data/lib/cooklang/formatter.rb +61 -0
- data/lib/cooklang/formatters/text.rb +18 -0
- data/lib/cooklang/ingredient.rb +60 -0
- data/lib/cooklang/lexer.rb +282 -0
- data/lib/cooklang/metadata.rb +98 -0
- data/lib/cooklang/note.rb +27 -0
- data/lib/cooklang/parser.rb +41 -0
- data/lib/cooklang/parsers/cookware_parser.rb +133 -0
- data/lib/cooklang/parsers/ingredient_parser.rb +179 -0
- data/lib/cooklang/parsers/timer_parser.rb +135 -0
- data/lib/cooklang/processors/element_parser.rb +45 -0
- data/lib/cooklang/processors/metadata_processor.rb +129 -0
- data/lib/cooklang/processors/step_processor.rb +208 -0
- data/lib/cooklang/processors/token_processor.rb +104 -0
- data/lib/cooklang/recipe.rb +72 -0
- data/lib/cooklang/section.rb +33 -0
- data/lib/cooklang/step.rb +99 -0
- data/lib/cooklang/timer.rb +65 -0
- data/lib/cooklang/token_stream.rb +130 -0
- data/lib/cooklang/version.rb +1 -1
- data/lib/cooklang.rb +22 -1
- data/spec/comprehensive_spec.rb +179 -0
- data/spec/cooklang_spec.rb +38 -0
- data/spec/fixtures/canonical.yaml +837 -0
- data/spec/formatters/text_spec.rb +189 -0
- data/spec/integration/canonical_spec.rb +211 -0
- data/spec/lexer_spec.rb +357 -0
- data/spec/models/cookware_spec.rb +116 -0
- data/spec/models/ingredient_spec.rb +192 -0
- data/spec/models/metadata_spec.rb +241 -0
- data/spec/models/note_spec.rb +65 -0
- data/spec/models/recipe_spec.rb +171 -0
- data/spec/models/section_spec.rb +65 -0
- data/spec/models/step_spec.rb +236 -0
- data/spec/models/timer_spec.rb +173 -0
- data/spec/parser_spec.rb +398 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/token_stream_spec.rb +278 -0
- metadata +141 -24
- data/.ruby-version +0 -1
- data/CHANGELOG.md +0 -5
- data/bin/console +0 -15
- data/bin/setup +0 -8
data/spec/parser_spec.rb
ADDED
@@ -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
|
data/spec/spec_helper.rb
ADDED
@@ -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
|