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
@@ -0,0 +1,179 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../ingredient"
|
4
|
+
require_relative "../token_stream"
|
5
|
+
|
6
|
+
module Cooklang
|
7
|
+
module Parsers
|
8
|
+
class IngredientParser
|
9
|
+
def initialize(stream)
|
10
|
+
@stream = stream
|
11
|
+
end
|
12
|
+
|
13
|
+
def parse
|
14
|
+
return nil unless @stream.current&.type == :ingredient_marker
|
15
|
+
@stream.consume(:ingredient_marker) # Skip the @ marker
|
16
|
+
|
17
|
+
return nil if invalid_syntax?
|
18
|
+
|
19
|
+
brace_index = find_next_brace
|
20
|
+
|
21
|
+
if brace_index
|
22
|
+
parse_braced_ingredient(brace_index)
|
23
|
+
else
|
24
|
+
parse_simple_ingredient
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
def invalid_syntax?
|
30
|
+
@stream.current&.type == :text && @stream.current.value.start_with?(" ")
|
31
|
+
end
|
32
|
+
|
33
|
+
def find_next_brace
|
34
|
+
@stream.find_next(:open_brace)
|
35
|
+
end
|
36
|
+
|
37
|
+
def parse_braced_ingredient(brace_index)
|
38
|
+
name = extract_name_until_brace(brace_index)
|
39
|
+
@stream.consume(:open_brace) # Skip open brace
|
40
|
+
|
41
|
+
quantity, unit = extract_quantity_and_unit
|
42
|
+
@stream.consume(:close_brace) # Skip close brace
|
43
|
+
|
44
|
+
notes = extract_notes
|
45
|
+
|
46
|
+
# Default values according to original logic
|
47
|
+
quantity = "some" if (quantity.nil? || quantity == "") && (unit.nil? || unit == "")
|
48
|
+
unit = nil if unit == ""
|
49
|
+
|
50
|
+
Ingredient.new(name: name, quantity: quantity, unit: unit, notes: notes)
|
51
|
+
end
|
52
|
+
|
53
|
+
def parse_simple_ingredient
|
54
|
+
remaining_text = nil
|
55
|
+
|
56
|
+
if @stream.current&.type == :text
|
57
|
+
text = @stream.current.value
|
58
|
+
if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
|
59
|
+
name = ::Regexp.last_match(1)
|
60
|
+
remaining_text = ::Regexp.last_match(2)
|
61
|
+
else
|
62
|
+
name = text.strip
|
63
|
+
end
|
64
|
+
@stream.consume
|
65
|
+
else
|
66
|
+
name = ""
|
67
|
+
end
|
68
|
+
|
69
|
+
ingredient = Ingredient.new(name: name, quantity: "some", unit: nil, notes: nil)
|
70
|
+
[ingredient, remaining_text]
|
71
|
+
end
|
72
|
+
|
73
|
+
def extract_name_until_brace(brace_index)
|
74
|
+
name_parts = []
|
75
|
+
@stream.position
|
76
|
+
|
77
|
+
while @stream.position < brace_index && !@stream.eof?
|
78
|
+
case @stream.current.type
|
79
|
+
when :text, :hyphen
|
80
|
+
name_parts << @stream.current.value
|
81
|
+
end
|
82
|
+
@stream.consume
|
83
|
+
end
|
84
|
+
|
85
|
+
name_parts.join.strip
|
86
|
+
end
|
87
|
+
|
88
|
+
def extract_quantity_and_unit
|
89
|
+
quantity = nil
|
90
|
+
unit = nil
|
91
|
+
text_parts = []
|
92
|
+
|
93
|
+
while !@stream.eof? && @stream.current.type != :close_brace
|
94
|
+
case @stream.current.type
|
95
|
+
when :percent
|
96
|
+
if !text_parts.empty?
|
97
|
+
quantity_text = text_parts.join.strip
|
98
|
+
quantity = parse_quantity_value(quantity_text)
|
99
|
+
end
|
100
|
+
text_parts = []
|
101
|
+
@stream.consume
|
102
|
+
when :text
|
103
|
+
text_parts << @stream.current.value
|
104
|
+
@stream.consume
|
105
|
+
else
|
106
|
+
@stream.consume
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
if !text_parts.empty?
|
111
|
+
combined_text = text_parts.join.strip
|
112
|
+
if quantity.nil?
|
113
|
+
# Try to parse as numeric quantity first, fallback to string if not purely numeric
|
114
|
+
if combined_text.match?(/^\d+$/)
|
115
|
+
quantity = combined_text.to_i
|
116
|
+
unit = ""
|
117
|
+
elsif combined_text.match?(/^\d+\.\d+$/)
|
118
|
+
quantity = combined_text.to_f
|
119
|
+
unit = ""
|
120
|
+
else
|
121
|
+
# Non-numeric content, keep as string quantity
|
122
|
+
quantity = combined_text
|
123
|
+
unit = ""
|
124
|
+
end
|
125
|
+
else
|
126
|
+
unit = combined_text
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
[quantity, unit]
|
131
|
+
end
|
132
|
+
|
133
|
+
def parse_quantity_value(text)
|
134
|
+
case text
|
135
|
+
when /^\d+$/
|
136
|
+
text.to_i
|
137
|
+
when /^\d+\.\d+$/
|
138
|
+
text.to_f
|
139
|
+
else
|
140
|
+
text
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def extract_quantity_from_text(text)
|
145
|
+
# Simple quantity extraction - this could use QuantityExtractor if needed
|
146
|
+
if text.match(/^(\d+(?:\.\d+)?)\s*(.*)$/)
|
147
|
+
quantity_val = ::Regexp.last_match(1)
|
148
|
+
rest = ::Regexp.last_match(2)
|
149
|
+
|
150
|
+
quantity = quantity_val.include?(".") ? quantity_val.to_f : quantity_val.to_i
|
151
|
+
unit = rest.empty? ? nil : rest
|
152
|
+
|
153
|
+
[quantity, unit]
|
154
|
+
else
|
155
|
+
[text, nil]
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def extract_notes
|
160
|
+
return nil unless @stream.current&.type == :open_paren
|
161
|
+
|
162
|
+
@stream.consume(:open_paren) # Skip open paren
|
163
|
+
notes_parts = []
|
164
|
+
|
165
|
+
while !@stream.eof? && @stream.current.type != :close_paren
|
166
|
+
if @stream.current.type == :text
|
167
|
+
notes_parts << @stream.current.value
|
168
|
+
end
|
169
|
+
@stream.consume
|
170
|
+
end
|
171
|
+
|
172
|
+
@stream.consume(:close_paren) if @stream.current&.type == :close_paren
|
173
|
+
|
174
|
+
notes = notes_parts.join.strip
|
175
|
+
notes.empty? ? nil : notes
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../timer"
|
4
|
+
require_relative "../token_stream"
|
5
|
+
|
6
|
+
module Cooklang
|
7
|
+
module Parsers
|
8
|
+
class TimerParser
|
9
|
+
def initialize(stream)
|
10
|
+
@stream = stream
|
11
|
+
end
|
12
|
+
|
13
|
+
def parse
|
14
|
+
return nil unless @stream.current&.type == :timer_marker
|
15
|
+
@stream.consume(:timer_marker) # Skip the ~ marker
|
16
|
+
|
17
|
+
return nil if invalid_syntax?
|
18
|
+
|
19
|
+
brace_index = find_next_brace
|
20
|
+
|
21
|
+
if brace_index
|
22
|
+
parse_named_timer(brace_index)
|
23
|
+
else
|
24
|
+
parse_simple_timer
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
def invalid_syntax?
|
30
|
+
@stream.current&.type == :text && @stream.current.value.start_with?(" ")
|
31
|
+
end
|
32
|
+
|
33
|
+
def find_next_brace
|
34
|
+
@stream.find_next(:open_brace)
|
35
|
+
end
|
36
|
+
|
37
|
+
def parse_named_timer(brace_index)
|
38
|
+
name = nil
|
39
|
+
|
40
|
+
# Extract name if there's text before the brace
|
41
|
+
if @stream.position < brace_index && @stream.current&.type == :text
|
42
|
+
name = @stream.current.value.strip
|
43
|
+
@stream.advance_to(brace_index)
|
44
|
+
end
|
45
|
+
|
46
|
+
@stream.consume(:open_brace) # Skip open brace
|
47
|
+
|
48
|
+
duration, unit = extract_duration
|
49
|
+
@stream.consume(:close_brace) # Skip close brace
|
50
|
+
|
51
|
+
Timer.new(name: name, duration: duration, unit: unit)
|
52
|
+
end
|
53
|
+
|
54
|
+
def parse_simple_timer
|
55
|
+
remaining_text = nil
|
56
|
+
name = nil
|
57
|
+
|
58
|
+
if @stream.current&.type == :text
|
59
|
+
text = @stream.current.value
|
60
|
+
if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
|
61
|
+
name = ::Regexp.last_match(1)
|
62
|
+
remaining_text = ::Regexp.last_match(2)
|
63
|
+
else
|
64
|
+
name = text.strip
|
65
|
+
end
|
66
|
+
@stream.consume
|
67
|
+
end
|
68
|
+
|
69
|
+
timer = Timer.new(name: name, duration: nil, unit: nil)
|
70
|
+
[timer, remaining_text]
|
71
|
+
end
|
72
|
+
|
73
|
+
def extract_duration
|
74
|
+
duration_parts = []
|
75
|
+
unit = nil
|
76
|
+
|
77
|
+
while !@stream.eof? && @stream.current.type != :close_brace
|
78
|
+
case @stream.current.type
|
79
|
+
when :text, :hyphen
|
80
|
+
duration_parts << @stream.current.value
|
81
|
+
@stream.consume
|
82
|
+
when :percent
|
83
|
+
@stream.consume
|
84
|
+
# Unit comes after percent
|
85
|
+
if @stream.current&.type == :text
|
86
|
+
unit = @stream.current.value
|
87
|
+
@stream.consume
|
88
|
+
end
|
89
|
+
else
|
90
|
+
@stream.consume
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
return [nil, nil] if duration_parts.empty?
|
95
|
+
|
96
|
+
duration_text = duration_parts.join
|
97
|
+
|
98
|
+
if unit
|
99
|
+
duration = parse_duration_value(duration_text)
|
100
|
+
[duration, unit]
|
101
|
+
else
|
102
|
+
parse_duration_with_unit(duration_text)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def parse_duration_value(text)
|
107
|
+
case text
|
108
|
+
when /^\d+$/
|
109
|
+
text.to_i
|
110
|
+
when /^\d+\.\d+$/
|
111
|
+
text.to_f
|
112
|
+
else
|
113
|
+
text # Keep ranges like "2-3"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def parse_duration_with_unit(text)
|
118
|
+
# Simple duration parsing - could be enhanced with DurationExtractor
|
119
|
+
# First check if it's just a number (integer or decimal)
|
120
|
+
if text.match?(/^(\d+(?:\.\d+)?)$/)
|
121
|
+
parsed_duration = text.include?(".") ? text.to_f : text.to_i
|
122
|
+
[parsed_duration, nil]
|
123
|
+
elsif text.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)$/)
|
124
|
+
duration = ::Regexp.last_match(1)
|
125
|
+
unit = ::Regexp.last_match(2)
|
126
|
+
|
127
|
+
parsed_duration = duration.include?(".") ? duration.to_f : duration.to_i
|
128
|
+
[parsed_duration, unit]
|
129
|
+
else
|
130
|
+
[text, nil]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../token_stream"
|
4
|
+
require_relative "../parsers/ingredient_parser"
|
5
|
+
require_relative "../parsers/cookware_parser"
|
6
|
+
require_relative "../parsers/timer_parser"
|
7
|
+
|
8
|
+
module Cooklang
|
9
|
+
module Processors
|
10
|
+
class ElementParser
|
11
|
+
class << self
|
12
|
+
def parse_ingredient(tokens, start_index)
|
13
|
+
parse_with_parser(tokens, start_index, Parsers::IngredientParser)
|
14
|
+
end
|
15
|
+
|
16
|
+
def parse_cookware(tokens, start_index)
|
17
|
+
parse_with_parser(tokens, start_index, Parsers::CookwareParser)
|
18
|
+
end
|
19
|
+
|
20
|
+
def parse_timer(tokens, start_index)
|
21
|
+
parse_with_parser(tokens, start_index, Parsers::TimerParser)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
def parse_with_parser(tokens, start_index, parser_class)
|
26
|
+
stream = TokenStream.new(tokens)
|
27
|
+
stream.advance_to(start_index)
|
28
|
+
|
29
|
+
parser = parser_class.new(stream)
|
30
|
+
result = parser.parse
|
31
|
+
consumed = stream.position - start_index
|
32
|
+
|
33
|
+
# Handle the return format
|
34
|
+
if result.is_a?(Array)
|
35
|
+
[result[0], consumed, result[1]]
|
36
|
+
elsif result
|
37
|
+
[result, consumed, nil]
|
38
|
+
else
|
39
|
+
[nil, 1, nil]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../metadata"
|
4
|
+
|
5
|
+
module Cooklang
|
6
|
+
module Processors
|
7
|
+
class MetadataProcessor
|
8
|
+
class << self
|
9
|
+
def extract_metadata(tokens)
|
10
|
+
metadata = Metadata.new
|
11
|
+
content_tokens = []
|
12
|
+
0
|
13
|
+
|
14
|
+
# Check for YAML front matter
|
15
|
+
if tokens.first&.type == :yaml_delimiter
|
16
|
+
metadata, content_tokens, _ = extract_yaml_frontmatter(tokens)
|
17
|
+
else
|
18
|
+
content_tokens = tokens.dup
|
19
|
+
end
|
20
|
+
|
21
|
+
# Extract inline metadata
|
22
|
+
extract_inline_metadata(content_tokens, metadata)
|
23
|
+
|
24
|
+
[metadata, content_tokens]
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
def extract_yaml_frontmatter(tokens)
|
29
|
+
metadata = Metadata.new
|
30
|
+
i = 1 # Skip the first ---
|
31
|
+
yaml_content = []
|
32
|
+
|
33
|
+
# Find the closing --- delimiter
|
34
|
+
while i < tokens.length
|
35
|
+
if tokens[i].type == :yaml_delimiter
|
36
|
+
# Found closing delimiter
|
37
|
+
break
|
38
|
+
elsif tokens[i].type == :text
|
39
|
+
yaml_content << tokens[i].value
|
40
|
+
elsif tokens[i].type == :newline
|
41
|
+
yaml_content << "\n"
|
42
|
+
elsif tokens[i].type == :metadata_marker
|
43
|
+
yaml_content << ">>"
|
44
|
+
end
|
45
|
+
i += 1
|
46
|
+
end
|
47
|
+
|
48
|
+
# Skip the closing --- if we found it
|
49
|
+
i += 1 if i < tokens.length && tokens[i].type == :yaml_delimiter
|
50
|
+
|
51
|
+
# Parse YAML content if we have any
|
52
|
+
if yaml_content.any?
|
53
|
+
yaml_text = yaml_content.join.strip
|
54
|
+
parse_yaml_content(yaml_text, metadata) unless yaml_text.empty?
|
55
|
+
end
|
56
|
+
|
57
|
+
# Return remaining tokens
|
58
|
+
remaining_tokens = tokens[i..]
|
59
|
+
|
60
|
+
[metadata, remaining_tokens, i]
|
61
|
+
end
|
62
|
+
|
63
|
+
def parse_yaml_content(yaml_text, metadata)
|
64
|
+
# Simple YAML parsing - split by lines and parse key-value pairs
|
65
|
+
yaml_text.split("\n").each do |line|
|
66
|
+
line = line.strip
|
67
|
+
next if line.empty? || line.start_with?("#")
|
68
|
+
|
69
|
+
if line.match(/^([^:]+):\s*(.*)$/)
|
70
|
+
key = ::Regexp.last_match(1).strip
|
71
|
+
value = ::Regexp.last_match(2).strip
|
72
|
+
|
73
|
+
# Remove quotes if present
|
74
|
+
value = value.gsub(/^["']|["']$/, "") if value.match?(/^["'].*["']$/)
|
75
|
+
|
76
|
+
# Parse numeric values
|
77
|
+
parsed_value = parse_metadata_value(value)
|
78
|
+
metadata[key] = parsed_value unless value.empty?
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def extract_inline_metadata(tokens, metadata)
|
84
|
+
tokens_to_remove = []
|
85
|
+
|
86
|
+
tokens.each_with_index do |token, index|
|
87
|
+
next unless token.type == :metadata_marker
|
88
|
+
|
89
|
+
# Look for metadata pattern: >> key: value
|
90
|
+
if index + 1 < tokens.length && tokens[index + 1].type == :text
|
91
|
+
text = tokens[index + 1].value.strip
|
92
|
+
|
93
|
+
if text.match(/^([^:]+):\s*(.+)$/)
|
94
|
+
key = ::Regexp.last_match(1).strip
|
95
|
+
value = ::Regexp.last_match(2).strip
|
96
|
+
|
97
|
+
# Remove quotes if present
|
98
|
+
value = value.gsub(/^["']|["']$/, "") if value.match?(/^["'].*["']$/)
|
99
|
+
|
100
|
+
# Parse numeric values
|
101
|
+
parsed_value = parse_metadata_value(value)
|
102
|
+
metadata[key] = parsed_value
|
103
|
+
|
104
|
+
# Mark both marker and text tokens for removal
|
105
|
+
tokens_to_remove << index << (index + 1)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Remove only the specific metadata tokens that were processed
|
111
|
+
tokens.reject!.with_index { |token, index| tokens_to_remove.include?(index) }
|
112
|
+
end
|
113
|
+
|
114
|
+
def parse_metadata_value(value)
|
115
|
+
return value if value.empty?
|
116
|
+
|
117
|
+
# Try to parse as integer
|
118
|
+
return value.to_i if value.match?(/^\d+$/)
|
119
|
+
|
120
|
+
# Try to parse as float
|
121
|
+
return value.to_f if value.match?(/^\d+\.\d+$/)
|
122
|
+
|
123
|
+
# Return as string
|
124
|
+
value
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,208 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "element_parser"
|
4
|
+
require_relative "../token_stream"
|
5
|
+
require_relative "../builders/step_builder"
|
6
|
+
|
7
|
+
module Cooklang
|
8
|
+
module Processors
|
9
|
+
class StepProcessor
|
10
|
+
class << self
|
11
|
+
def parse_steps(tokens)
|
12
|
+
# Group tokens into steps (separated by blank lines)
|
13
|
+
step_groups = split_into_step_groups(tokens)
|
14
|
+
|
15
|
+
# Parse each step group into step data
|
16
|
+
steps = step_groups.map { |step_tokens| parse_step(step_tokens) }
|
17
|
+
|
18
|
+
# Filter out nil steps (those without content)
|
19
|
+
steps.compact.select { |step_data| step_data.is_a?(Hash) && has_content?(step_data[:segments]) }
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
def split_into_step_groups(tokens)
|
24
|
+
groups = []
|
25
|
+
current_group = []
|
26
|
+
i = 0
|
27
|
+
|
28
|
+
while i < tokens.length
|
29
|
+
token = tokens[i]
|
30
|
+
|
31
|
+
if token.type == :newline && blank_line_ahead?(tokens, i)
|
32
|
+
groups = finalize_current_group(groups, current_group)
|
33
|
+
current_group = []
|
34
|
+
i = skip_blank_line(tokens, i)
|
35
|
+
else
|
36
|
+
current_group << token
|
37
|
+
i += 1
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
finalize_current_group(groups, current_group)
|
42
|
+
end
|
43
|
+
|
44
|
+
def blank_line_ahead?(tokens, index)
|
45
|
+
next_index = index + 1
|
46
|
+
return false if next_index >= tokens.length
|
47
|
+
|
48
|
+
# Look for consecutive newlines or newline + whitespace + newline
|
49
|
+
if tokens[next_index].type == :newline
|
50
|
+
true
|
51
|
+
elsif tokens[next_index].type == :text && tokens[next_index].value.strip.empty?
|
52
|
+
# Check if there's a newline after the whitespace
|
53
|
+
next_next_index = next_index + 1
|
54
|
+
next_next_index < tokens.length && tokens[next_next_index].type == :newline
|
55
|
+
else
|
56
|
+
false
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def parse_step(tokens)
|
61
|
+
builder = Builders::StepBuilder.new
|
62
|
+
stream = TokenStream.new(tokens)
|
63
|
+
|
64
|
+
while !stream.eof?
|
65
|
+
process_token_with_stream(stream, builder)
|
66
|
+
end
|
67
|
+
|
68
|
+
return nil unless builder.has_content?
|
69
|
+
|
70
|
+
# Return the expected hash format for compatibility
|
71
|
+
{
|
72
|
+
segments: builder.send(:remove_trailing_newlines, builder.segments.dup),
|
73
|
+
ingredients: builder.ingredients,
|
74
|
+
cookware: builder.cookware,
|
75
|
+
timers: builder.timers,
|
76
|
+
section_name: builder.section_name
|
77
|
+
}
|
78
|
+
end
|
79
|
+
|
80
|
+
def process_token_with_stream(stream, builder)
|
81
|
+
token = stream.current
|
82
|
+
|
83
|
+
case token.type
|
84
|
+
when :ingredient_marker
|
85
|
+
process_ingredient_with_stream(stream, builder)
|
86
|
+
when :cookware_marker
|
87
|
+
process_cookware_with_stream(stream, builder)
|
88
|
+
when :timer_marker
|
89
|
+
process_timer_with_stream(stream, builder)
|
90
|
+
when :text
|
91
|
+
builder.add_text(token.value)
|
92
|
+
stream.consume
|
93
|
+
when :newline
|
94
|
+
builder.add_text("\n")
|
95
|
+
stream.consume
|
96
|
+
when :yaml_delimiter
|
97
|
+
# Preserve yaml_delimiter as literal text when not in YAML context
|
98
|
+
builder.add_text(token.value)
|
99
|
+
stream.consume
|
100
|
+
when :open_brace, :close_brace, :open_paren, :close_paren, :percent
|
101
|
+
builder.add_text(token.value)
|
102
|
+
stream.consume
|
103
|
+
when :section_marker
|
104
|
+
process_section_with_stream(stream, builder)
|
105
|
+
else
|
106
|
+
stream.consume
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
|
111
|
+
def process_ingredient_with_stream(stream, builder)
|
112
|
+
process_element_with_stream(stream, builder, :ingredient)
|
113
|
+
end
|
114
|
+
|
115
|
+
def process_cookware_with_stream(stream, builder)
|
116
|
+
process_element_with_stream(stream, builder, :cookware)
|
117
|
+
end
|
118
|
+
|
119
|
+
def process_timer_with_stream(stream, builder)
|
120
|
+
process_element_with_stream(stream, builder, :timer)
|
121
|
+
end
|
122
|
+
|
123
|
+
def process_element_with_stream(stream, builder, element_type)
|
124
|
+
# Get the current position to pass to ElementParser
|
125
|
+
start_index = stream.position
|
126
|
+
tokens = stream.tokens
|
127
|
+
|
128
|
+
# Parse the element using the appropriate ElementParser method
|
129
|
+
element, consumed, remaining_text = case element_type
|
130
|
+
when :ingredient
|
131
|
+
ElementParser.parse_ingredient(tokens, start_index)
|
132
|
+
when :cookware
|
133
|
+
ElementParser.parse_cookware(tokens, start_index)
|
134
|
+
when :timer
|
135
|
+
ElementParser.parse_timer(tokens, start_index)
|
136
|
+
end
|
137
|
+
|
138
|
+
if element.nil?
|
139
|
+
builder.add_text(stream.current.value)
|
140
|
+
stream.consume
|
141
|
+
else
|
142
|
+
# Add the element using the appropriate builder method
|
143
|
+
case element_type
|
144
|
+
when :ingredient
|
145
|
+
builder.add_ingredient(element)
|
146
|
+
when :cookware
|
147
|
+
builder.add_cookware(element)
|
148
|
+
when :timer
|
149
|
+
builder.add_timer(element)
|
150
|
+
end
|
151
|
+
|
152
|
+
builder.add_remaining_text(remaining_text)
|
153
|
+
# Move stream position forward by consumed tokens
|
154
|
+
stream.advance_to(start_index + consumed)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def process_section_with_stream(stream, builder)
|
159
|
+
stream.consume # Skip section marker
|
160
|
+
|
161
|
+
if stream.current&.type == :text
|
162
|
+
text_content = stream.current.value
|
163
|
+
# Handle both actual newlines and literal \n sequences
|
164
|
+
newline_pos = text_content.index("\n") || text_content.index("\\n")
|
165
|
+
|
166
|
+
if newline_pos
|
167
|
+
# Section name is everything before the newline
|
168
|
+
section_name = text_content[0...newline_pos].strip
|
169
|
+
builder.set_section_name(section_name)
|
170
|
+
else
|
171
|
+
# No newline in this token, take the whole text as section name
|
172
|
+
section_name = text_content.strip
|
173
|
+
builder.set_section_name(section_name)
|
174
|
+
end
|
175
|
+
stream.consume
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
|
180
|
+
|
181
|
+
|
182
|
+
|
183
|
+
|
184
|
+
def has_content?(segments)
|
185
|
+
segments.any? { |segment| segment != "\n" && !(segment.is_a?(String) && segment.strip.empty?) }
|
186
|
+
end
|
187
|
+
|
188
|
+
def finalize_current_group(groups, current_group)
|
189
|
+
return groups unless group_has_content?(current_group)
|
190
|
+
|
191
|
+
groups << current_group
|
192
|
+
groups
|
193
|
+
end
|
194
|
+
|
195
|
+
def group_has_content?(group)
|
196
|
+
group.any? { |t| t.type != :newline }
|
197
|
+
end
|
198
|
+
|
199
|
+
def skip_blank_line(tokens, start_index)
|
200
|
+
i = start_index + 1
|
201
|
+
i += 1 while i < tokens.length && (tokens[i].type == :newline ||
|
202
|
+
(tokens[i].type == :text && tokens[i].value.strip.empty?))
|
203
|
+
i
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|