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
@@ -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
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../lexer"
|
4
|
+
require_relative "../note"
|
5
|
+
|
6
|
+
module Cooklang
|
7
|
+
module Processors
|
8
|
+
class TokenProcessor
|
9
|
+
class << self
|
10
|
+
def strip_comments(tokens)
|
11
|
+
result = []
|
12
|
+
i = 0
|
13
|
+
|
14
|
+
while i < tokens.length
|
15
|
+
token = tokens[i]
|
16
|
+
|
17
|
+
case token.type
|
18
|
+
when :comment_line
|
19
|
+
i = handle_comment_line(tokens, i, result)
|
20
|
+
when :comment_block_start
|
21
|
+
i = skip_comment_block(tokens, i)
|
22
|
+
else
|
23
|
+
result << token
|
24
|
+
i += 1
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
result
|
29
|
+
end
|
30
|
+
|
31
|
+
def extract_notes(tokens)
|
32
|
+
notes = []
|
33
|
+
content_tokens = []
|
34
|
+
i = 0
|
35
|
+
|
36
|
+
while i < tokens.length
|
37
|
+
if tokens[i].type == :note_marker
|
38
|
+
note_content, i = extract_single_note(tokens, i)
|
39
|
+
notes << Note.new(content: note_content) unless note_content.empty?
|
40
|
+
else
|
41
|
+
content_tokens << tokens[i]
|
42
|
+
i += 1
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
[notes, content_tokens]
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
def extract_single_note(tokens, start_index)
|
51
|
+
note_text = ""
|
52
|
+
i = start_index + 1 # Skip >
|
53
|
+
|
54
|
+
while i < tokens.length && tokens[i].type != :newline
|
55
|
+
note_text += tokens[i].value if tokens[i].type == :text
|
56
|
+
i += 1
|
57
|
+
end
|
58
|
+
|
59
|
+
[note_text.strip, i]
|
60
|
+
end
|
61
|
+
|
62
|
+
def handle_comment_line(tokens, index, result)
|
63
|
+
index += 1
|
64
|
+
return skip_to_newline(tokens, index) unless index < tokens.length && tokens[index].type == :text
|
65
|
+
|
66
|
+
text_token = tokens[index]
|
67
|
+
newline_pos = text_token.value.index("\n")
|
68
|
+
|
69
|
+
if newline_pos
|
70
|
+
handle_text_with_newline(text_token, newline_pos, result)
|
71
|
+
index + 1
|
72
|
+
else
|
73
|
+
skip_to_newline(tokens, index + 1)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def handle_text_with_newline(text_token, newline_pos, result)
|
78
|
+
remaining_text = text_token.value[(newline_pos + 1)..]
|
79
|
+
return unless remaining_text && !remaining_text.empty?
|
80
|
+
|
81
|
+
result << Token.new(
|
82
|
+
:text,
|
83
|
+
remaining_text,
|
84
|
+
text_token.position,
|
85
|
+
text_token.line,
|
86
|
+
text_token.column
|
87
|
+
)
|
88
|
+
end
|
89
|
+
|
90
|
+
def skip_to_newline(tokens, index)
|
91
|
+
index += 1 while index < tokens.length && tokens[index].type != :newline
|
92
|
+
index
|
93
|
+
end
|
94
|
+
|
95
|
+
def skip_comment_block(tokens, index)
|
96
|
+
index += 1
|
97
|
+
index += 1 while index < tokens.length && tokens[index].type != :comment_block_end
|
98
|
+
index += 1 if index < tokens.length && tokens[index].type == :comment_block_end
|
99
|
+
index
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
data/lib/cooklang/recipe.rb
CHANGED
@@ -4,16 +4,23 @@ module Cooklang
|
|
4
4
|
class Recipe
|
5
5
|
attr_reader :ingredients, :cookware, :timers, :steps, :metadata, :sections, :notes
|
6
6
|
|
7
|
-
def initialize(
|
8
|
-
@ingredients = ingredients
|
9
|
-
@cookware = cookware
|
10
|
-
@timers = timers
|
11
|
-
@steps = steps
|
12
|
-
@metadata = metadata
|
13
|
-
@sections = sections
|
14
|
-
@notes = notes
|
7
|
+
def initialize(**components)
|
8
|
+
@ingredients = freeze_component(components[:ingredients])
|
9
|
+
@cookware = freeze_component(components[:cookware])
|
10
|
+
@timers = freeze_component(components[:timers])
|
11
|
+
@steps = freeze_component(components[:steps])
|
12
|
+
@metadata = components[:metadata] || Metadata.new
|
13
|
+
@sections = freeze_component(components[:sections])
|
14
|
+
@notes = freeze_component(components[:notes])
|
15
15
|
end
|
16
16
|
|
17
|
+
private
|
18
|
+
def freeze_component(value)
|
19
|
+
(value || []).freeze
|
20
|
+
end
|
21
|
+
|
22
|
+
public
|
23
|
+
|
17
24
|
def ingredients_hash
|
18
25
|
@ingredients.each_with_object({}) do |ingredient, hash|
|
19
26
|
hash[ingredient.name] = {
|
@@ -42,15 +49,18 @@ module Cooklang
|
|
42
49
|
def ==(other)
|
43
50
|
return false unless other.is_a?(Recipe)
|
44
51
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
steps == other.steps &&
|
49
|
-
metadata == other.metadata &&
|
50
|
-
sections == other.sections &&
|
51
|
-
notes == other.notes
|
52
|
+
comparable_attributes.all? do |attr|
|
53
|
+
send(attr) == other.send(attr)
|
54
|
+
end
|
52
55
|
end
|
53
56
|
|
57
|
+
private
|
58
|
+
def comparable_attributes
|
59
|
+
%i[ingredients cookware timers steps metadata sections notes]
|
60
|
+
end
|
61
|
+
|
62
|
+
public
|
63
|
+
|
54
64
|
def eql?(other)
|
55
65
|
self == other
|
56
66
|
end
|
data/lib/cooklang/step.rb
CHANGED
@@ -9,7 +9,7 @@ module Cooklang
|
|
9
9
|
end
|
10
10
|
|
11
11
|
def to_text
|
12
|
-
@segments.map do |segment|
|
12
|
+
text_parts = @segments.map do |segment|
|
13
13
|
case segment
|
14
14
|
when Hash
|
15
15
|
case segment[:type]
|
@@ -39,7 +39,17 @@ module Cooklang
|
|
39
39
|
else
|
40
40
|
segment.to_s
|
41
41
|
end
|
42
|
-
end
|
42
|
+
end
|
43
|
+
|
44
|
+
result = text_parts.join
|
45
|
+
|
46
|
+
# Special normalization for metadata break patterns:
|
47
|
+
# Convert sequences like "--- \n text \n ---" to "--- text ---"
|
48
|
+
if result.include?("---")
|
49
|
+
result = result.gsub(/---\s*\n\s*/, "--- ").gsub(/\s*\n\s*---/, " ---")
|
50
|
+
end
|
51
|
+
|
52
|
+
result.rstrip
|
43
53
|
end
|
44
54
|
|
45
55
|
def to_h
|
data/lib/cooklang/timer.rb
CHANGED
@@ -7,7 +7,7 @@ module Cooklang
|
|
7
7
|
def initialize(duration:, unit:, name: nil)
|
8
8
|
@name = name&.to_s&.freeze
|
9
9
|
@duration = duration
|
10
|
-
@unit = unit
|
10
|
+
@unit = unit&.to_s&.freeze
|
11
11
|
end
|
12
12
|
|
13
13
|
def to_s
|
@@ -42,6 +42,8 @@ module Cooklang
|
|
42
42
|
end
|
43
43
|
|
44
44
|
def total_seconds
|
45
|
+
return @duration unless @unit
|
46
|
+
|
45
47
|
case @unit.downcase
|
46
48
|
when "second", "seconds", "sec", "s"
|
47
49
|
@duration
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module Cooklang
|
6
|
+
class TokenStream
|
7
|
+
include Enumerable
|
8
|
+
extend Forwardable
|
9
|
+
|
10
|
+
# Delegate array-like methods to @tokens
|
11
|
+
def_delegators :@tokens, :size, :length, :empty?
|
12
|
+
|
13
|
+
attr_reader :position
|
14
|
+
|
15
|
+
def initialize(tokens)
|
16
|
+
@tokens = tokens
|
17
|
+
@position = 0
|
18
|
+
end
|
19
|
+
|
20
|
+
def current
|
21
|
+
@tokens[@position]
|
22
|
+
end
|
23
|
+
|
24
|
+
def peek(offset = 1)
|
25
|
+
@tokens[@position + offset]
|
26
|
+
end
|
27
|
+
|
28
|
+
def consume(expected_type = nil)
|
29
|
+
return nil if eof?
|
30
|
+
return nil if expected_type && current&.type != expected_type
|
31
|
+
|
32
|
+
token = current
|
33
|
+
@position += 1
|
34
|
+
token
|
35
|
+
end
|
36
|
+
|
37
|
+
def eof?
|
38
|
+
@position >= @tokens.length
|
39
|
+
end
|
40
|
+
|
41
|
+
# Ruby Enumerable support
|
42
|
+
def each
|
43
|
+
while !eof?
|
44
|
+
yield consume
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# StringScanner-inspired methods
|
49
|
+
def scan(type)
|
50
|
+
consume if check(type)
|
51
|
+
end
|
52
|
+
|
53
|
+
def check(type)
|
54
|
+
current&.type == type
|
55
|
+
end
|
56
|
+
|
57
|
+
def skip(type)
|
58
|
+
@position += 1 if check(type)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Convenience methods for complex parsing
|
62
|
+
def consume_while(&block)
|
63
|
+
result = []
|
64
|
+
while !eof? && block.call(current)
|
65
|
+
result << consume
|
66
|
+
end
|
67
|
+
result
|
68
|
+
end
|
69
|
+
|
70
|
+
def consume_until(&block)
|
71
|
+
result = []
|
72
|
+
until eof? || block.call(current)
|
73
|
+
result << consume
|
74
|
+
end
|
75
|
+
result
|
76
|
+
end
|
77
|
+
|
78
|
+
def skip_whitespace
|
79
|
+
consume_while { |token| token.type == :whitespace }
|
80
|
+
end
|
81
|
+
|
82
|
+
# Advanced iteration with lookahead
|
83
|
+
def each_with_lookahead
|
84
|
+
return enum_for(:each_with_lookahead) unless block_given?
|
85
|
+
|
86
|
+
(0...(@tokens.length - 1)).each do |i|
|
87
|
+
yield @tokens[i], @tokens[i + 1]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# StringScanner-inspired position methods
|
92
|
+
def rest
|
93
|
+
@tokens[@position..]
|
94
|
+
end
|
95
|
+
|
96
|
+
def reset
|
97
|
+
@position = 0
|
98
|
+
end
|
99
|
+
|
100
|
+
def rewind(steps = 1)
|
101
|
+
@position = [@position - steps, 0].max
|
102
|
+
end
|
103
|
+
|
104
|
+
# Utility methods
|
105
|
+
def find_next(type)
|
106
|
+
(@position...@tokens.length).find { |i| @tokens[i].type == type }
|
107
|
+
end
|
108
|
+
|
109
|
+
def find_next_matching(&block)
|
110
|
+
(@position...@tokens.length).find { |i| block.call(@tokens[i]) }
|
111
|
+
end
|
112
|
+
|
113
|
+
# Create a new stream starting from current position
|
114
|
+
def slice_from_current
|
115
|
+
TokenStream.new(@tokens[@position..])
|
116
|
+
end
|
117
|
+
|
118
|
+
# Public interface methods to avoid instance_variable_get/set
|
119
|
+
attr_reader :tokens
|
120
|
+
|
121
|
+
def position=(new_position)
|
122
|
+
@position = [new_position, 0].max
|
123
|
+
@position = [@position, @tokens.length].min
|
124
|
+
end
|
125
|
+
|
126
|
+
def advance_to(new_position)
|
127
|
+
self.position = new_position
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
data/lib/cooklang/version.rb
CHANGED