cooklang 1.0.0 → 1.0.2

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 (52) 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 +197 -17
  12. data/Rakefile +12 -0
  13. data/cooklang.gemspec +35 -0
  14. data/lib/cooklang/builders/recipe_builder.rb +60 -0
  15. data/lib/cooklang/builders/step_builder.rb +74 -0
  16. data/lib/cooklang/formatter.rb +6 -2
  17. data/lib/cooklang/formatters/hash.rb +257 -0
  18. data/lib/cooklang/formatters/json.rb +13 -0
  19. data/lib/cooklang/formatters/text.rb +1 -3
  20. data/lib/cooklang/lexer.rb +5 -14
  21. data/lib/cooklang/parser.rb +18 -653
  22. data/lib/cooklang/parsers/cookware_parser.rb +130 -0
  23. data/lib/cooklang/parsers/ingredient_parser.rb +176 -0
  24. data/lib/cooklang/parsers/timer_parser.rb +132 -0
  25. data/lib/cooklang/processors/element_parser.rb +40 -0
  26. data/lib/cooklang/processors/metadata_processor.rb +127 -0
  27. data/lib/cooklang/processors/step_processor.rb +204 -0
  28. data/lib/cooklang/processors/token_processor.rb +101 -0
  29. data/lib/cooklang/recipe.rb +31 -24
  30. data/lib/cooklang/step.rb +12 -2
  31. data/lib/cooklang/timer.rb +3 -1
  32. data/lib/cooklang/token_stream.rb +130 -0
  33. data/lib/cooklang/version.rb +1 -1
  34. data/lib/cooklang.rb +31 -3
  35. data/spec/comprehensive_spec.rb +179 -0
  36. data/spec/cooklang_spec.rb +38 -0
  37. data/spec/fixtures/canonical.yaml +837 -0
  38. data/spec/formatters/text_spec.rb +189 -0
  39. data/spec/integration/canonical_spec.rb +211 -0
  40. data/spec/lexer_spec.rb +357 -0
  41. data/spec/models/cookware_spec.rb +116 -0
  42. data/spec/models/ingredient_spec.rb +192 -0
  43. data/spec/models/metadata_spec.rb +241 -0
  44. data/spec/models/note_spec.rb +65 -0
  45. data/spec/models/recipe_spec.rb +374 -0
  46. data/spec/models/section_spec.rb +65 -0
  47. data/spec/models/step_spec.rb +236 -0
  48. data/spec/models/timer_spec.rb +173 -0
  49. data/spec/parser_spec.rb +398 -0
  50. data/spec/spec_helper.rb +23 -0
  51. data/spec/token_stream_spec.rb +278 -0
  52. metadata +159 -4
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Processors
5
+ class StepProcessor
6
+ class << self
7
+ def parse_steps(tokens)
8
+ # Group tokens into steps (separated by blank lines)
9
+ step_groups = split_into_step_groups(tokens)
10
+
11
+ # Parse each step group into step data
12
+ steps = step_groups.map { |step_tokens| parse_step(step_tokens) }
13
+
14
+ # Filter out nil steps (those without content)
15
+ steps.compact.select { |step_data| step_data.is_a?(Hash) && has_content?(step_data[:segments]) }
16
+ end
17
+
18
+ private
19
+ def split_into_step_groups(tokens)
20
+ groups = []
21
+ current_group = []
22
+ i = 0
23
+
24
+ while i < tokens.length
25
+ token = tokens[i]
26
+
27
+ if token.type == :newline && blank_line_ahead?(tokens, i)
28
+ groups = finalize_current_group(groups, current_group)
29
+ current_group = []
30
+ i = skip_blank_line(tokens, i)
31
+ else
32
+ current_group << token
33
+ i += 1
34
+ end
35
+ end
36
+
37
+ finalize_current_group(groups, current_group)
38
+ end
39
+
40
+ def blank_line_ahead?(tokens, index)
41
+ next_index = index + 1
42
+ return false if next_index >= tokens.length
43
+
44
+ # Look for consecutive newlines or newline + whitespace + newline
45
+ if tokens[next_index].type == :newline
46
+ true
47
+ elsif tokens[next_index].type == :text && tokens[next_index].value.strip.empty?
48
+ # Check if there's a newline after the whitespace
49
+ next_next_index = next_index + 1
50
+ next_next_index < tokens.length && tokens[next_next_index].type == :newline
51
+ else
52
+ false
53
+ end
54
+ end
55
+
56
+ def parse_step(tokens)
57
+ builder = Builders::StepBuilder.new
58
+ stream = TokenStream.new(tokens)
59
+
60
+ while !stream.eof?
61
+ process_token_with_stream(stream, builder)
62
+ end
63
+
64
+ return nil unless builder.has_content?
65
+
66
+ # Return the expected hash format for compatibility
67
+ {
68
+ segments: builder.send(:remove_trailing_newlines, builder.segments.dup),
69
+ ingredients: builder.ingredients,
70
+ cookware: builder.cookware,
71
+ timers: builder.timers,
72
+ section_name: builder.section_name
73
+ }
74
+ end
75
+
76
+ def process_token_with_stream(stream, builder)
77
+ token = stream.current
78
+
79
+ case token.type
80
+ when :ingredient_marker
81
+ process_ingredient_with_stream(stream, builder)
82
+ when :cookware_marker
83
+ process_cookware_with_stream(stream, builder)
84
+ when :timer_marker
85
+ process_timer_with_stream(stream, builder)
86
+ when :text
87
+ builder.add_text(token.value)
88
+ stream.consume
89
+ when :newline
90
+ builder.add_text("\n")
91
+ stream.consume
92
+ when :yaml_delimiter
93
+ # Preserve yaml_delimiter as literal text when not in YAML context
94
+ builder.add_text(token.value)
95
+ stream.consume
96
+ when :open_brace, :close_brace, :open_paren, :close_paren, :percent
97
+ builder.add_text(token.value)
98
+ stream.consume
99
+ when :section_marker
100
+ process_section_with_stream(stream, builder)
101
+ else
102
+ stream.consume
103
+ end
104
+ end
105
+
106
+
107
+ def process_ingredient_with_stream(stream, builder)
108
+ process_element_with_stream(stream, builder, :ingredient)
109
+ end
110
+
111
+ def process_cookware_with_stream(stream, builder)
112
+ process_element_with_stream(stream, builder, :cookware)
113
+ end
114
+
115
+ def process_timer_with_stream(stream, builder)
116
+ process_element_with_stream(stream, builder, :timer)
117
+ end
118
+
119
+ def process_element_with_stream(stream, builder, element_type)
120
+ # Get the current position to pass to ElementParser
121
+ start_index = stream.position
122
+ tokens = stream.tokens
123
+
124
+ # Parse the element using the appropriate ElementParser method
125
+ element, consumed, remaining_text = case element_type
126
+ when :ingredient
127
+ ElementParser.parse_ingredient(tokens, start_index)
128
+ when :cookware
129
+ ElementParser.parse_cookware(tokens, start_index)
130
+ when :timer
131
+ ElementParser.parse_timer(tokens, start_index)
132
+ end
133
+
134
+ if element.nil?
135
+ builder.add_text(stream.current.value)
136
+ stream.consume
137
+ else
138
+ # Add the element using the appropriate builder method
139
+ case element_type
140
+ when :ingredient
141
+ builder.add_ingredient(element)
142
+ when :cookware
143
+ builder.add_cookware(element)
144
+ when :timer
145
+ builder.add_timer(element)
146
+ end
147
+
148
+ builder.add_remaining_text(remaining_text)
149
+ # Move stream position forward by consumed tokens
150
+ stream.advance_to(start_index + consumed)
151
+ end
152
+ end
153
+
154
+ def process_section_with_stream(stream, builder)
155
+ stream.consume # Skip section marker
156
+
157
+ if stream.current&.type == :text
158
+ text_content = stream.current.value
159
+ # Handle both actual newlines and literal \n sequences
160
+ newline_pos = text_content.index("\n") || text_content.index("\\n")
161
+
162
+ if newline_pos
163
+ # Section name is everything before the newline
164
+ section_name = text_content[0...newline_pos].strip
165
+ builder.set_section_name(section_name)
166
+ else
167
+ # No newline in this token, take the whole text as section name
168
+ section_name = text_content.strip
169
+ builder.set_section_name(section_name)
170
+ end
171
+ stream.consume
172
+ end
173
+ end
174
+
175
+
176
+
177
+
178
+
179
+
180
+ def has_content?(segments)
181
+ segments.any? { |segment| segment != "\n" && !(segment.is_a?(String) && segment.strip.empty?) }
182
+ end
183
+
184
+ def finalize_current_group(groups, current_group)
185
+ return groups unless group_has_content?(current_group)
186
+
187
+ groups << current_group
188
+ groups
189
+ end
190
+
191
+ def group_has_content?(group)
192
+ group.any? { |t| t.type != :newline }
193
+ end
194
+
195
+ def skip_blank_line(tokens, start_index)
196
+ i = start_index + 1
197
+ i += 1 while i < tokens.length && (tokens[i].type == :newline ||
198
+ (tokens[i].type == :text && tokens[i].value.strip.empty?))
199
+ i
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Processors
5
+ class TokenProcessor
6
+ class << self
7
+ def strip_comments(tokens)
8
+ result = []
9
+ i = 0
10
+
11
+ while i < tokens.length
12
+ token = tokens[i]
13
+
14
+ case token.type
15
+ when :comment_line
16
+ i = handle_comment_line(tokens, i, result)
17
+ when :comment_block_start
18
+ i = skip_comment_block(tokens, i)
19
+ else
20
+ result << token
21
+ i += 1
22
+ end
23
+ end
24
+
25
+ result
26
+ end
27
+
28
+ def extract_notes(tokens)
29
+ notes = []
30
+ content_tokens = []
31
+ i = 0
32
+
33
+ while i < tokens.length
34
+ if tokens[i].type == :note_marker
35
+ note_content, i = extract_single_note(tokens, i)
36
+ notes << Note.new(content: note_content) unless note_content.empty?
37
+ else
38
+ content_tokens << tokens[i]
39
+ i += 1
40
+ end
41
+ end
42
+
43
+ [notes, content_tokens]
44
+ end
45
+
46
+ private
47
+ def extract_single_note(tokens, start_index)
48
+ note_text = ""
49
+ i = start_index + 1 # Skip >
50
+
51
+ while i < tokens.length && tokens[i].type != :newline
52
+ note_text += tokens[i].value if tokens[i].type == :text
53
+ i += 1
54
+ end
55
+
56
+ [note_text.strip, i]
57
+ end
58
+
59
+ def handle_comment_line(tokens, index, result)
60
+ index += 1
61
+ return skip_to_newline(tokens, index) unless index < tokens.length && tokens[index].type == :text
62
+
63
+ text_token = tokens[index]
64
+ newline_pos = text_token.value.index("\n")
65
+
66
+ if newline_pos
67
+ handle_text_with_newline(text_token, newline_pos, result)
68
+ index + 1
69
+ else
70
+ skip_to_newline(tokens, index + 1)
71
+ end
72
+ end
73
+
74
+ def handle_text_with_newline(text_token, newline_pos, result)
75
+ remaining_text = text_token.value[(newline_pos + 1)..]
76
+ return unless remaining_text && !remaining_text.empty?
77
+
78
+ result << Token.new(
79
+ :text,
80
+ remaining_text,
81
+ text_token.position,
82
+ text_token.line,
83
+ text_token.column
84
+ )
85
+ end
86
+
87
+ def skip_to_newline(tokens, index)
88
+ index += 1 while index < tokens.length && tokens[index].type != :newline
89
+ index
90
+ end
91
+
92
+ def skip_comment_block(tokens, index)
93
+ index += 1
94
+ index += 1 while index < tokens.length && tokens[index].type != :comment_block_end
95
+ index += 1 if index < tokens.length && tokens[index].type == :comment_block_end
96
+ index
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -4,16 +4,24 @@ 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
+
23
+ public
24
+
17
25
  def ingredients_hash
18
26
  @ingredients.each_with_object({}) do |ingredient, hash|
19
27
  hash[ingredient.name] = {
@@ -28,29 +36,28 @@ module Cooklang
28
36
  end
29
37
 
30
38
  def to_h
31
- {
32
- ingredients: @ingredients.map(&:to_h),
33
- cookware: @cookware.map(&:to_h),
34
- timers: @timers.map(&:to_h),
35
- steps: @steps.map(&:to_h),
36
- metadata: @metadata.to_h,
37
- sections: @sections.map(&:to_h),
38
- notes: @notes.map(&:to_h)
39
- }
39
+ Formatters::Hash.new(self).generate
40
+ end
41
+
42
+ def to_json(*args)
43
+ Formatters::Json.new(self).generate(*args)
40
44
  end
41
45
 
42
46
  def ==(other)
43
47
  return false unless other.is_a?(Recipe)
44
48
 
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
49
+ comparable_attributes.all? do |attr|
50
+ send(attr) == other.send(attr)
51
+ end
52
52
  end
53
53
 
54
+ private
55
+ def comparable_attributes
56
+ %i[ingredients cookware timers steps metadata sections notes]
57
+ end
58
+
59
+ public
60
+
54
61
  def eql?(other)
55
62
  self == other
56
63
  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.2"
5
5
  end
data/lib/cooklang.rb CHANGED
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "cooklang/version"
4
- require_relative "cooklang/lexer"
5
- require_relative "cooklang/parser"
6
- require_relative "cooklang/recipe"
4
+
5
+ # Core classes
7
6
  require_relative "cooklang/ingredient"
8
7
  require_relative "cooklang/cookware"
9
8
  require_relative "cooklang/timer"
@@ -11,8 +10,37 @@ require_relative "cooklang/step"
11
10
  require_relative "cooklang/metadata"
12
11
  require_relative "cooklang/section"
13
12
  require_relative "cooklang/note"
13
+
14
+ # Parsing infrastructure
15
+ require_relative "cooklang/token_stream"
16
+ require_relative "cooklang/lexer"
17
+
18
+ # Parsers (depend on token_stream and core classes)
19
+ require_relative "cooklang/parsers/ingredient_parser"
20
+ require_relative "cooklang/parsers/cookware_parser"
21
+ require_relative "cooklang/parsers/timer_parser"
22
+
23
+ # Processors (depend on parsers and token_stream)
24
+ require_relative "cooklang/processors/element_parser"
25
+ require_relative "cooklang/processors/metadata_processor"
26
+ require_relative "cooklang/processors/token_processor"
27
+
28
+ # Builders (depend on core classes)
29
+ require_relative "cooklang/builders/step_builder"
30
+ require_relative "cooklang/builders/recipe_builder"
31
+
32
+ # Processors that depend on builders
33
+ require_relative "cooklang/processors/step_processor"
34
+
35
+ # Main parser (depends on processors and builders)
36
+ require_relative "cooklang/parser"
37
+
38
+ # Recipe (depends on formatters)
14
39
  require_relative "cooklang/formatter"
40
+ require_relative "cooklang/formatters/hash"
41
+ require_relative "cooklang/formatters/json"
15
42
  require_relative "cooklang/formatters/text"
43
+ require_relative "cooklang/recipe"
16
44
 
17
45
  module Cooklang
18
46
  class Error < StandardError; end