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.
- 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 +340 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +84 -0
- data/README.md +10 -5
- data/Rakefile +12 -0
- 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/lexer.rb +5 -14
- data/lib/cooklang/parser.rb +24 -653
- 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 +25 -15
- data/lib/cooklang/step.rb +12 -2
- data/lib/cooklang/timer.rb +3 -1
- data/lib/cooklang/token_stream.rb +130 -0
- data/lib/cooklang/version.rb +1 -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 +162 -6
data/lib/cooklang/parser.rb
CHANGED
@@ -1,670 +1,41 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "lexer"
|
4
|
+
require_relative "processors/metadata_processor"
|
5
|
+
require_relative "processors/token_processor"
|
6
|
+
require_relative "processors/step_processor"
|
7
|
+
require_relative "builders/recipe_builder"
|
8
|
+
|
3
9
|
module Cooklang
|
4
10
|
class Parser
|
5
11
|
def parse(input)
|
12
|
+
# Tokenize input
|
6
13
|
lexer = Lexer.new(input)
|
7
14
|
tokens = lexer.tokenize
|
8
15
|
|
9
|
-
# Extract metadata
|
10
|
-
metadata, content_tokens = extract_metadata(tokens)
|
11
|
-
|
12
|
-
# Extract notes
|
13
|
-
notes, content_tokens = extract_notes(content_tokens)
|
14
|
-
|
15
|
-
# Remove comments
|
16
|
-
content_tokens = strip_comments(content_tokens)
|
16
|
+
# Extract metadata
|
17
|
+
metadata, content_tokens = Processors::MetadataProcessor.extract_metadata(tokens)
|
17
18
|
|
18
|
-
#
|
19
|
-
|
19
|
+
# Clean up tokens
|
20
|
+
cleaned_tokens = Processors::TokenProcessor.strip_comments(content_tokens)
|
21
|
+
notes, recipe_tokens = Processors::TokenProcessor.extract_notes(cleaned_tokens)
|
20
22
|
|
21
|
-
# Parse
|
22
|
-
|
23
|
+
# Parse steps
|
24
|
+
parsed_steps = Processors::StepProcessor.parse_steps(recipe_tokens)
|
23
25
|
|
24
|
-
#
|
25
|
-
|
26
|
-
cookware = []
|
27
|
-
timers = []
|
28
|
-
|
29
|
-
steps.each do |step|
|
30
|
-
ingredients.concat(step[:ingredients])
|
31
|
-
cookware.concat(step[:cookware])
|
32
|
-
timers.concat(step[:timers])
|
33
|
-
end
|
34
|
-
|
35
|
-
# Convert steps to Step objects, filtering out whitespace-only steps
|
36
|
-
step_objects = steps.map { |step| Step.new(segments: step[:segments]) }
|
37
|
-
.reject { |step| step.to_text.strip.empty? }
|
26
|
+
# Build final recipe
|
27
|
+
recipe = Builders::RecipeBuilder.build_recipe(parsed_steps, metadata)
|
38
28
|
|
29
|
+
# Add notes to recipe (create new recipe with notes)
|
39
30
|
Recipe.new(
|
40
|
-
ingredients: ingredients
|
41
|
-
cookware:
|
42
|
-
timers: timers,
|
43
|
-
steps:
|
44
|
-
|
45
|
-
|
46
|
-
|
31
|
+
ingredients: recipe.ingredients,
|
32
|
+
cookware: recipe.cookware,
|
33
|
+
timers: recipe.timers,
|
34
|
+
steps: recipe.steps,
|
35
|
+
metadata: recipe.metadata,
|
36
|
+
sections: recipe.sections,
|
37
|
+
notes: notes
|
47
38
|
)
|
48
39
|
end
|
49
|
-
|
50
|
-
private
|
51
|
-
def extract_metadata(tokens)
|
52
|
-
metadata = {}
|
53
|
-
content_start = 0
|
54
|
-
|
55
|
-
# Check for YAML front matter
|
56
|
-
if tokens.first&.type == :yaml_delimiter
|
57
|
-
yaml_end = find_yaml_end(tokens)
|
58
|
-
if yaml_end
|
59
|
-
yaml_content = extract_yaml_content(tokens[1...yaml_end])
|
60
|
-
metadata.merge!(yaml_content)
|
61
|
-
content_start = yaml_end + 1
|
62
|
-
end
|
63
|
-
end
|
64
|
-
|
65
|
-
# Extract >> style metadata from remaining tokens
|
66
|
-
remaining_tokens = tokens[content_start..]
|
67
|
-
metadata_tokens, content_tokens = extract_inline_metadata(remaining_tokens)
|
68
|
-
|
69
|
-
metadata_tokens.each do |token|
|
70
|
-
next unless token.type == :metadata_marker
|
71
|
-
|
72
|
-
# Next token should be the key: value text
|
73
|
-
next_token = metadata_tokens[metadata_tokens.index(token) + 1]
|
74
|
-
parse_metadata_line(next_token.value, metadata) if next_token&.type == :text
|
75
|
-
end
|
76
|
-
|
77
|
-
[metadata, content_tokens]
|
78
|
-
end
|
79
|
-
|
80
|
-
def find_yaml_end(tokens)
|
81
|
-
tokens[1..].find_index { |token| token.type == :yaml_delimiter }&.+(1)
|
82
|
-
end
|
83
|
-
|
84
|
-
def extract_yaml_content(yaml_tokens)
|
85
|
-
yaml_text = yaml_tokens.filter_map { |t| t.value if %i[text newline].include?(t.type) }
|
86
|
-
.join
|
87
|
-
|
88
|
-
# Simple YAML parsing for key: value pairs
|
89
|
-
metadata = {}
|
90
|
-
yaml_text.split("\n").each do |line|
|
91
|
-
line = line.strip
|
92
|
-
next if line.empty?
|
93
|
-
|
94
|
-
next unless line.match(/^([^:]+):\s*(.*)$/)
|
95
|
-
|
96
|
-
key = ::Regexp.last_match(1).strip
|
97
|
-
value = ::Regexp.last_match(2).strip
|
98
|
-
metadata[key] = parse_metadata_value(value)
|
99
|
-
end
|
100
|
-
|
101
|
-
metadata
|
102
|
-
end
|
103
|
-
|
104
|
-
def extract_inline_metadata(tokens)
|
105
|
-
metadata_tokens = []
|
106
|
-
content_tokens = []
|
107
|
-
i = 0
|
108
|
-
|
109
|
-
while i < tokens.length
|
110
|
-
token = tokens[i]
|
111
|
-
|
112
|
-
if token.type == :metadata_marker
|
113
|
-
# Collect metadata line
|
114
|
-
metadata_tokens << token
|
115
|
-
i += 1
|
116
|
-
|
117
|
-
# Collect the rest of the line
|
118
|
-
while i < tokens.length && tokens[i].type != :newline
|
119
|
-
metadata_tokens << tokens[i]
|
120
|
-
i += 1
|
121
|
-
end
|
122
|
-
|
123
|
-
# Include the newline
|
124
|
-
if i < tokens.length && tokens[i].type == :newline
|
125
|
-
metadata_tokens << tokens[i]
|
126
|
-
i + 1
|
127
|
-
end
|
128
|
-
else
|
129
|
-
content_tokens << token
|
130
|
-
i += 1
|
131
|
-
end
|
132
|
-
end
|
133
|
-
|
134
|
-
[metadata_tokens, content_tokens]
|
135
|
-
end
|
136
|
-
|
137
|
-
def parse_metadata_line(text, metadata)
|
138
|
-
return unless text.match(/^\s*(\w+):\s*(.*)$/)
|
139
|
-
|
140
|
-
key = ::Regexp.last_match(1).strip
|
141
|
-
value = ::Regexp.last_match(2).strip
|
142
|
-
metadata[key] = parse_metadata_value(value)
|
143
|
-
end
|
144
|
-
|
145
|
-
def parse_metadata_value(value)
|
146
|
-
# Try to parse as number
|
147
|
-
if value.match?(/^\d+$/)
|
148
|
-
value.to_i
|
149
|
-
elsif value.match?(/^\d+\.\d+$/)
|
150
|
-
value.to_f
|
151
|
-
else
|
152
|
-
value
|
153
|
-
end
|
154
|
-
end
|
155
|
-
|
156
|
-
def strip_comments(tokens)
|
157
|
-
result = []
|
158
|
-
i = 0
|
159
|
-
|
160
|
-
while i < tokens.length
|
161
|
-
token = tokens[i]
|
162
|
-
|
163
|
-
case token.type
|
164
|
-
when :comment_line
|
165
|
-
# Skip until newline or process text with embedded newline
|
166
|
-
i += 1
|
167
|
-
if i < tokens.length && tokens[i].type == :text
|
168
|
-
# Check if this text contains a newline
|
169
|
-
text = tokens[i].value
|
170
|
-
newline_index = text.index("\n")
|
171
|
-
if newline_index
|
172
|
-
# Split the text at the newline, keep the part after newline
|
173
|
-
remaining_text = text[(newline_index + 1)..]
|
174
|
-
if remaining_text && !remaining_text.empty?
|
175
|
-
# Create a new token with the remaining text and add it to result
|
176
|
-
result << Token.new(
|
177
|
-
:text,
|
178
|
-
remaining_text,
|
179
|
-
tokens[i].position,
|
180
|
-
tokens[i].line,
|
181
|
-
tokens[i].column
|
182
|
-
)
|
183
|
-
else
|
184
|
-
# No remaining text, skip this token
|
185
|
-
end
|
186
|
-
i += 1
|
187
|
-
else
|
188
|
-
# No newline in this text, skip it entirely (it's all comment)
|
189
|
-
i += 1
|
190
|
-
# Continue skipping until we find a newline token
|
191
|
-
i += 1 while i < tokens.length && tokens[i].type != :newline
|
192
|
-
# Preserve the newline - don't skip it, it will be processed normally
|
193
|
-
end
|
194
|
-
else
|
195
|
-
# Look for separate newline token
|
196
|
-
i += 1 while i < tokens.length && tokens[i].type != :newline
|
197
|
-
# Preserve the newline - don't skip it, it will be processed normally
|
198
|
-
end
|
199
|
-
when :comment_block_start
|
200
|
-
# Skip until comment_block_end
|
201
|
-
i += 1
|
202
|
-
i += 1 while i < tokens.length && tokens[i].type != :comment_block_end
|
203
|
-
# Skip the comment_block_end token
|
204
|
-
i += 1 if i < tokens.length && tokens[i].type == :comment_block_end
|
205
|
-
else
|
206
|
-
result << token
|
207
|
-
i += 1
|
208
|
-
end
|
209
|
-
end
|
210
|
-
|
211
|
-
result
|
212
|
-
end
|
213
|
-
|
214
|
-
def split_into_steps(tokens)
|
215
|
-
steps = []
|
216
|
-
current_step = []
|
217
|
-
consecutive_newlines = 0
|
218
|
-
|
219
|
-
tokens.each do |token|
|
220
|
-
if token.type == :newline
|
221
|
-
consecutive_newlines += 1
|
222
|
-
current_step << token
|
223
|
-
|
224
|
-
# Two consecutive newlines = step boundary
|
225
|
-
if consecutive_newlines >= 2 && !current_step.empty?
|
226
|
-
steps << current_step.dup
|
227
|
-
current_step.clear
|
228
|
-
consecutive_newlines = 0
|
229
|
-
end
|
230
|
-
else
|
231
|
-
consecutive_newlines = 0
|
232
|
-
current_step << token
|
233
|
-
end
|
234
|
-
end
|
235
|
-
|
236
|
-
# Add the last step if it has content
|
237
|
-
steps << current_step unless current_step.empty?
|
238
|
-
|
239
|
-
# Filter out steps that are only newlines
|
240
|
-
steps.select { |step| step.any? { |token| token.type != :newline } }
|
241
|
-
end
|
242
|
-
|
243
|
-
def parse_step(tokens)
|
244
|
-
segments = []
|
245
|
-
ingredients = []
|
246
|
-
cookware = []
|
247
|
-
timers = []
|
248
|
-
i = 0
|
249
|
-
|
250
|
-
while i < tokens.length
|
251
|
-
token = tokens[i]
|
252
|
-
|
253
|
-
case token.type
|
254
|
-
when :ingredient_marker
|
255
|
-
ingredient, consumed, remaining_text = parse_ingredient_at(tokens, i)
|
256
|
-
if ingredient.nil?
|
257
|
-
# Invalid ingredient syntax, treat @ as plain text
|
258
|
-
segments << token.value
|
259
|
-
i += 1
|
260
|
-
else
|
261
|
-
ingredients << ingredient
|
262
|
-
segments << ingredient
|
263
|
-
# Add any remaining text from partial token consumption
|
264
|
-
segments << remaining_text if remaining_text && !remaining_text.empty?
|
265
|
-
i += consumed
|
266
|
-
end
|
267
|
-
when :cookware_marker
|
268
|
-
cookware_item, consumed, remaining_text = parse_cookware_at(tokens, i)
|
269
|
-
if cookware_item.nil?
|
270
|
-
# Invalid cookware syntax, treat # as plain text
|
271
|
-
segments << token.value
|
272
|
-
i += 1
|
273
|
-
else
|
274
|
-
cookware << cookware_item
|
275
|
-
segments << cookware_item
|
276
|
-
# Add any remaining text from partial token consumption
|
277
|
-
segments << remaining_text if remaining_text && !remaining_text.empty?
|
278
|
-
i += consumed
|
279
|
-
end
|
280
|
-
when :timer_marker
|
281
|
-
timer, consumed, remaining_text = parse_timer_at(tokens, i)
|
282
|
-
if timer.nil?
|
283
|
-
# Invalid timer syntax, treat ~ as plain text
|
284
|
-
segments << token.value
|
285
|
-
i += 1
|
286
|
-
else
|
287
|
-
timers << timer
|
288
|
-
segments << timer
|
289
|
-
# Add any remaining text from partial token consumption
|
290
|
-
segments << remaining_text if remaining_text && !remaining_text.empty?
|
291
|
-
i += consumed
|
292
|
-
end
|
293
|
-
when :text
|
294
|
-
segments << token.value
|
295
|
-
i += 1
|
296
|
-
when :newline
|
297
|
-
segments << "\n"
|
298
|
-
i += 1
|
299
|
-
when :yaml_delimiter
|
300
|
-
# YAML delimiters outside of frontmatter should be treated as text
|
301
|
-
segments << token.value
|
302
|
-
i += 1
|
303
|
-
when :open_brace, :close_brace, :open_paren, :close_paren, :percent
|
304
|
-
# Standalone punctuation tokens should be treated as text
|
305
|
-
segments << token.value
|
306
|
-
i += 1
|
307
|
-
else
|
308
|
-
i += 1
|
309
|
-
end
|
310
|
-
end
|
311
|
-
|
312
|
-
# Clean up segments - remove trailing newlines
|
313
|
-
segments = remove_trailing_newlines(segments)
|
314
|
-
|
315
|
-
{
|
316
|
-
segments: segments,
|
317
|
-
ingredients: ingredients,
|
318
|
-
cookware: cookware,
|
319
|
-
timers: timers
|
320
|
-
}
|
321
|
-
end
|
322
|
-
|
323
|
-
def parse_ingredient_at(tokens, start_index)
|
324
|
-
i = start_index + 1 # Skip the @ marker
|
325
|
-
name = ""
|
326
|
-
quantity = nil
|
327
|
-
unit = nil
|
328
|
-
notes = nil
|
329
|
-
remaining_text = nil
|
330
|
-
|
331
|
-
# Check for invalid syntax first
|
332
|
-
if i < tokens.length && tokens[i].type == :text && tokens[i].value.start_with?(" ")
|
333
|
-
# Return nil to indicate invalid ingredient (e.g., "@ example")
|
334
|
-
return [nil, 1, nil]
|
335
|
-
end
|
336
|
-
|
337
|
-
# Look ahead to see if there's a brace - if so, collect everything until the brace
|
338
|
-
brace_index = find_next_brace(tokens, i)
|
339
|
-
|
340
|
-
if brace_index
|
341
|
-
# Collect all tokens until the brace as the name
|
342
|
-
name_parts = []
|
343
|
-
while i < brace_index
|
344
|
-
case tokens[i].type
|
345
|
-
when :text
|
346
|
-
name_parts << tokens[i].value
|
347
|
-
when :hyphen
|
348
|
-
name_parts << tokens[i].value
|
349
|
-
end
|
350
|
-
i += 1
|
351
|
-
end
|
352
|
-
name = name_parts.join.strip
|
353
|
-
elsif i < tokens.length && tokens[i].type == :text
|
354
|
-
# No brace - take only the first word as the ingredient name, stopping at punctuation
|
355
|
-
text = tokens[i].value
|
356
|
-
if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
|
357
|
-
name = ::Regexp.last_match(1)
|
358
|
-
remaining_text = ::Regexp.last_match(2)
|
359
|
-
else
|
360
|
-
# No match, use entire text as name
|
361
|
-
name = text.strip
|
362
|
-
end
|
363
|
-
i += 1
|
364
|
-
end
|
365
|
-
|
366
|
-
# Parse quantity/unit if present
|
367
|
-
if i < tokens.length && tokens[i].type == :open_brace
|
368
|
-
i += 1 # Skip {
|
369
|
-
quantity_text = ""
|
370
|
-
|
371
|
-
while i < tokens.length && tokens[i].type != :close_brace
|
372
|
-
if tokens[i].type == :percent
|
373
|
-
# Split quantity and unit
|
374
|
-
if tokens[i + 1]&.type == :text
|
375
|
-
unit = tokens[i + 1].value.strip
|
376
|
-
i += 2
|
377
|
-
else
|
378
|
-
i += 1
|
379
|
-
end
|
380
|
-
elsif tokens[i].type == :text
|
381
|
-
quantity_text += tokens[i].value
|
382
|
-
i += 1
|
383
|
-
else
|
384
|
-
i += 1
|
385
|
-
end
|
386
|
-
end
|
387
|
-
|
388
|
-
i += 1 if i < tokens.length && tokens[i].type == :close_brace # Skip }
|
389
|
-
|
390
|
-
# Parse quantity
|
391
|
-
quantity_text = quantity_text.strip
|
392
|
-
if quantity_text.match?(/^\d+$/)
|
393
|
-
quantity = quantity_text.to_i
|
394
|
-
elsif quantity_text.match?(/^\d+\.\d+$/)
|
395
|
-
quantity = quantity_text.to_f
|
396
|
-
elsif !quantity_text.empty?
|
397
|
-
quantity = quantity_text
|
398
|
-
end
|
399
|
-
end
|
400
|
-
|
401
|
-
# Parse notes if present
|
402
|
-
if i < tokens.length && tokens[i].type == :open_paren
|
403
|
-
i += 1 # Skip (
|
404
|
-
notes_text = ""
|
405
|
-
|
406
|
-
while i < tokens.length && tokens[i].type != :close_paren
|
407
|
-
notes_text += tokens[i].value if tokens[i].type == :text
|
408
|
-
i += 1
|
409
|
-
end
|
410
|
-
|
411
|
-
i += 1 if i < tokens.length && tokens[i].type == :close_paren # Skip )
|
412
|
-
notes = notes_text.strip unless notes_text.strip.empty?
|
413
|
-
end
|
414
|
-
|
415
|
-
# Set default quantity to "some" if not specified
|
416
|
-
quantity = "some" if quantity.nil? && unit.nil?
|
417
|
-
|
418
|
-
ingredient = Ingredient.new(
|
419
|
-
name: name,
|
420
|
-
quantity: quantity,
|
421
|
-
unit: unit,
|
422
|
-
notes: notes
|
423
|
-
)
|
424
|
-
|
425
|
-
[ingredient, i - start_index, remaining_text]
|
426
|
-
end
|
427
|
-
|
428
|
-
def parse_cookware_at(tokens, start_index)
|
429
|
-
i = start_index + 1 # Skip the # marker
|
430
|
-
name = ""
|
431
|
-
quantity = nil
|
432
|
-
remaining_text = nil
|
433
|
-
|
434
|
-
# Check for invalid syntax first
|
435
|
-
if i < tokens.length && tokens[i].type == :text && tokens[i].value.start_with?(" ")
|
436
|
-
# Return nil to indicate invalid cookware (e.g., "# example")
|
437
|
-
return [nil, 1, nil]
|
438
|
-
end
|
439
|
-
|
440
|
-
# Look ahead to see if there's a brace - if so, collect everything until the brace
|
441
|
-
brace_index = find_next_brace(tokens, i)
|
442
|
-
|
443
|
-
if brace_index
|
444
|
-
# Collect all tokens until the brace as the name, but stop at other markers
|
445
|
-
name_parts = []
|
446
|
-
found_other_marker = false
|
447
|
-
while i < brace_index
|
448
|
-
case tokens[i].type
|
449
|
-
when :text
|
450
|
-
name_parts << tokens[i].value
|
451
|
-
when :hyphen
|
452
|
-
name_parts << tokens[i].value
|
453
|
-
when :ingredient_marker, :cookware_marker, :timer_marker
|
454
|
-
# Stop at other markers - this brace belongs to them
|
455
|
-
found_other_marker = true
|
456
|
-
break
|
457
|
-
end
|
458
|
-
i += 1
|
459
|
-
end
|
460
|
-
|
461
|
-
if found_other_marker
|
462
|
-
# Brace belongs to another element, parse as single-word cookware
|
463
|
-
brace_index = nil
|
464
|
-
i = start_index + 1 # Reset position
|
465
|
-
else
|
466
|
-
name = name_parts.join.strip
|
467
|
-
end
|
468
|
-
end
|
469
|
-
|
470
|
-
if !brace_index && i < tokens.length && tokens[i].type == :text
|
471
|
-
# No brace - take only the first word as the cookware name, stopping at punctuation
|
472
|
-
text = tokens[i].value
|
473
|
-
if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
|
474
|
-
name = ::Regexp.last_match(1)
|
475
|
-
remaining_text = ::Regexp.last_match(2)
|
476
|
-
else
|
477
|
-
# No match, use entire text as name
|
478
|
-
name = text.strip
|
479
|
-
end
|
480
|
-
i += 1
|
481
|
-
end
|
482
|
-
|
483
|
-
# Parse quantity if present
|
484
|
-
if i < tokens.length && tokens[i].type == :open_brace
|
485
|
-
i += 1 # Skip {
|
486
|
-
quantity_text = ""
|
487
|
-
|
488
|
-
while i < tokens.length && tokens[i].type != :close_brace
|
489
|
-
quantity_text += tokens[i].value if tokens[i].type == :text
|
490
|
-
i += 1
|
491
|
-
end
|
492
|
-
|
493
|
-
i += 1 if i < tokens.length && tokens[i].type == :close_brace # Skip }
|
494
|
-
|
495
|
-
# Parse quantity
|
496
|
-
quantity_text = quantity_text.strip
|
497
|
-
if quantity_text.match?(/^\d+$/)
|
498
|
-
quantity = quantity_text.to_i
|
499
|
-
elsif !quantity_text.empty?
|
500
|
-
quantity = quantity_text
|
501
|
-
end
|
502
|
-
end
|
503
|
-
|
504
|
-
# Set default quantity to 1 if not specified
|
505
|
-
quantity = 1 if quantity.nil?
|
506
|
-
|
507
|
-
cookware_item = Cookware.new(name: name, quantity: quantity)
|
508
|
-
[cookware_item, i - start_index, remaining_text]
|
509
|
-
end
|
510
|
-
|
511
|
-
def parse_timer_at(tokens, start_index)
|
512
|
-
i = start_index + 1 # Skip the ~ marker
|
513
|
-
name = nil
|
514
|
-
duration = nil
|
515
|
-
unit = nil
|
516
|
-
remaining_text = nil
|
517
|
-
|
518
|
-
# Check for invalid syntax first
|
519
|
-
if i < tokens.length && tokens[i].type == :text && tokens[i].value.start_with?(" ")
|
520
|
-
# Return nil to indicate invalid timer (e.g., "~ example")
|
521
|
-
return [nil, 1, nil]
|
522
|
-
end
|
523
|
-
|
524
|
-
# Check if we have a name before { or standalone name
|
525
|
-
if i < tokens.length && tokens[i].type == :text && !tokens[i].value.strip.empty?
|
526
|
-
next_brace_index = find_next_brace(tokens, i)
|
527
|
-
if next_brace_index
|
528
|
-
# We have a name before braces
|
529
|
-
name = tokens[i].value.strip
|
530
|
-
i = next_brace_index
|
531
|
-
else
|
532
|
-
# Standalone timer name (e.g., ~rest) - take only the first word
|
533
|
-
text = tokens[i].value
|
534
|
-
if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
|
535
|
-
name = ::Regexp.last_match(1)
|
536
|
-
remaining_text = ::Regexp.last_match(2)
|
537
|
-
else
|
538
|
-
# No match, use entire text as name
|
539
|
-
name = text.strip
|
540
|
-
end
|
541
|
-
i += 1
|
542
|
-
end
|
543
|
-
end
|
544
|
-
|
545
|
-
# Parse duration/unit
|
546
|
-
if i < tokens.length && tokens[i].type == :open_brace
|
547
|
-
i += 1 # Skip {
|
548
|
-
duration_text = ""
|
549
|
-
|
550
|
-
while i < tokens.length && tokens[i].type != :close_brace
|
551
|
-
case tokens[i].type
|
552
|
-
when :percent
|
553
|
-
# Split duration and unit
|
554
|
-
if tokens[i + 1]&.type == :text
|
555
|
-
unit = tokens[i + 1].value.strip
|
556
|
-
i += 2
|
557
|
-
else
|
558
|
-
i += 1
|
559
|
-
end
|
560
|
-
when :text
|
561
|
-
duration_text += tokens[i].value
|
562
|
-
i += 1
|
563
|
-
when :hyphen
|
564
|
-
duration_text += tokens[i].value
|
565
|
-
i += 1
|
566
|
-
else
|
567
|
-
# Skip other token types
|
568
|
-
i += 1
|
569
|
-
end
|
570
|
-
end
|
571
|
-
|
572
|
-
i += 1 if i < tokens.length && tokens[i].type == :close_brace # Skip }
|
573
|
-
|
574
|
-
# Parse duration
|
575
|
-
duration_text = duration_text.strip
|
576
|
-
if duration_text.match?(/^\d+$/)
|
577
|
-
duration = duration_text.to_i
|
578
|
-
elsif duration_text.match?(/^\d+\.\d+$/)
|
579
|
-
duration = duration_text.to_f
|
580
|
-
elsif !duration_text.empty?
|
581
|
-
duration = duration_text # Keep as string for ranges like "2-3"
|
582
|
-
end
|
583
|
-
end
|
584
|
-
|
585
|
-
timer = Timer.new(name: name, duration: duration, unit: unit)
|
586
|
-
[timer, i - start_index, remaining_text]
|
587
|
-
end
|
588
|
-
|
589
|
-
def find_next_brace(tokens, start_index)
|
590
|
-
(start_index...tokens.length).find { |i| tokens[i].type == :open_brace }
|
591
|
-
end
|
592
|
-
|
593
|
-
def deduplicate_cookware(cookware_items)
|
594
|
-
# Group by name and prefer items with quantity over those without
|
595
|
-
cookware_items.group_by(&:name).map do |_name, items|
|
596
|
-
# Prefer items with quantity, then take the first one
|
597
|
-
items.find(&:quantity) || items.first
|
598
|
-
end
|
599
|
-
end
|
600
|
-
|
601
|
-
def remove_trailing_newlines(segments)
|
602
|
-
# Remove trailing newlines and whitespace-only text segments
|
603
|
-
segments.pop while segments.last == "\n" || (segments.last.is_a?(String) && segments.last.strip.empty?)
|
604
|
-
segments
|
605
|
-
end
|
606
|
-
|
607
|
-
def extract_notes(tokens)
|
608
|
-
notes = []
|
609
|
-
content_tokens = []
|
610
|
-
i = 0
|
611
|
-
|
612
|
-
while i < tokens.length
|
613
|
-
token = tokens[i]
|
614
|
-
|
615
|
-
if token.type == :note_marker
|
616
|
-
# Collect note content until newline
|
617
|
-
note_content = ""
|
618
|
-
i += 1
|
619
|
-
|
620
|
-
while i < tokens.length && tokens[i].type != :newline
|
621
|
-
note_content += tokens[i].value if tokens[i].type == :text
|
622
|
-
i += 1
|
623
|
-
end
|
624
|
-
|
625
|
-
notes << Note.new(content: note_content.strip) unless note_content.strip.empty?
|
626
|
-
|
627
|
-
# Skip the newline
|
628
|
-
i + 1 if i < tokens.length && tokens[i].type == :newline
|
629
|
-
else
|
630
|
-
content_tokens << token
|
631
|
-
i += 1
|
632
|
-
end
|
633
|
-
end
|
634
|
-
|
635
|
-
[notes, content_tokens]
|
636
|
-
end
|
637
|
-
|
638
|
-
def extract_sections_and_steps(tokens)
|
639
|
-
# For now, just return empty sections and use the original step splitting
|
640
|
-
# This ensures backward compatibility while sections support is being implemented
|
641
|
-
sections = []
|
642
|
-
|
643
|
-
# Extract sections but don't group steps by them yet
|
644
|
-
tokens.each_with_index do |token, i|
|
645
|
-
next unless token.type == :section_marker
|
646
|
-
|
647
|
-
# Parse section name
|
648
|
-
section_name = ""
|
649
|
-
j = i + 1
|
650
|
-
|
651
|
-
# Collect text until another section marker or newline
|
652
|
-
while j < tokens.length && tokens[j].type != :section_marker && tokens[j].type != :newline
|
653
|
-
section_name += tokens[j].value if tokens[j].type == :text
|
654
|
-
j += 1
|
655
|
-
end
|
656
|
-
|
657
|
-
section_name = section_name.strip
|
658
|
-
section_name = nil if section_name.empty?
|
659
|
-
|
660
|
-
# Create section (without steps for now)
|
661
|
-
sections << Section.new(name: section_name, steps: []) if section_name
|
662
|
-
end
|
663
|
-
|
664
|
-
# Use original step splitting logic
|
665
|
-
step_token_groups = split_into_steps(tokens)
|
666
|
-
|
667
|
-
[sections, step_token_groups]
|
668
|
-
end
|
669
40
|
end
|
670
41
|
end
|