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
@@ -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 first
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
- # Extract sections and split into steps
19
- sections, step_token_groups = extract_sections_and_steps(content_tokens)
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 each step
22
- steps = step_token_groups.map { |step_tokens| parse_step(step_tokens) }
23
+ # Parse steps
24
+ parsed_steps = Processors::StepProcessor.parse_steps(recipe_tokens)
23
25
 
24
- # Extract ingredients, cookware, timers from all steps
25
- ingredients = []
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.uniq { |i| [i.name, i.quantity, i.unit] },
41
- cookware: deduplicate_cookware(cookware),
42
- timers: timers,
43
- steps: step_objects,
44
- sections: sections,
45
- notes: notes,
46
- metadata: Metadata.new(metadata)
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