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
@@ -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
@@ -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(ingredients:, cookware:, timers:, steps:, metadata:, sections: [], notes: [])
8
- @ingredients = ingredients.freeze
9
- @cookware = cookware.freeze
10
- @timers = timers.freeze
11
- @steps = steps.freeze
12
- @metadata = metadata
13
- @sections = sections.freeze
14
- @notes = notes.freeze
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
- ingredients == other.ingredients &&
46
- cookware == other.cookware &&
47
- timers == other.timers &&
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.join.rstrip
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
@@ -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.to_s.freeze
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cooklang
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.1"
5
5
  end