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.
Files changed (59) 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 +289 -75
  9. data/Gemfile.lock +65 -26
  10. data/{LICENSE → LICENSE.txt} +6 -6
  11. data/README.md +106 -12
  12. data/Rakefile +5 -1
  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/cookware.rb +43 -0
  17. data/lib/cooklang/formatter.rb +61 -0
  18. data/lib/cooklang/formatters/text.rb +18 -0
  19. data/lib/cooklang/ingredient.rb +60 -0
  20. data/lib/cooklang/lexer.rb +282 -0
  21. data/lib/cooklang/metadata.rb +98 -0
  22. data/lib/cooklang/note.rb +27 -0
  23. data/lib/cooklang/parser.rb +41 -0
  24. data/lib/cooklang/parsers/cookware_parser.rb +133 -0
  25. data/lib/cooklang/parsers/ingredient_parser.rb +179 -0
  26. data/lib/cooklang/parsers/timer_parser.rb +135 -0
  27. data/lib/cooklang/processors/element_parser.rb +45 -0
  28. data/lib/cooklang/processors/metadata_processor.rb +129 -0
  29. data/lib/cooklang/processors/step_processor.rb +208 -0
  30. data/lib/cooklang/processors/token_processor.rb +104 -0
  31. data/lib/cooklang/recipe.rb +72 -0
  32. data/lib/cooklang/section.rb +33 -0
  33. data/lib/cooklang/step.rb +99 -0
  34. data/lib/cooklang/timer.rb +65 -0
  35. data/lib/cooklang/token_stream.rb +130 -0
  36. data/lib/cooklang/version.rb +1 -1
  37. data/lib/cooklang.rb +22 -1
  38. data/spec/comprehensive_spec.rb +179 -0
  39. data/spec/cooklang_spec.rb +38 -0
  40. data/spec/fixtures/canonical.yaml +837 -0
  41. data/spec/formatters/text_spec.rb +189 -0
  42. data/spec/integration/canonical_spec.rb +211 -0
  43. data/spec/lexer_spec.rb +357 -0
  44. data/spec/models/cookware_spec.rb +116 -0
  45. data/spec/models/ingredient_spec.rb +192 -0
  46. data/spec/models/metadata_spec.rb +241 -0
  47. data/spec/models/note_spec.rb +65 -0
  48. data/spec/models/recipe_spec.rb +171 -0
  49. data/spec/models/section_spec.rb +65 -0
  50. data/spec/models/step_spec.rb +236 -0
  51. data/spec/models/timer_spec.rb +173 -0
  52. data/spec/parser_spec.rb +398 -0
  53. data/spec/spec_helper.rb +23 -0
  54. data/spec/token_stream_spec.rb +278 -0
  55. metadata +141 -24
  56. data/.ruby-version +0 -1
  57. data/CHANGELOG.md +0 -5
  58. data/bin/console +0 -15
  59. data/bin/setup +0 -8
@@ -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
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ class Recipe
5
+ attr_reader :ingredients, :cookware, :timers, :steps, :metadata, :sections, :notes
6
+
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
+ end
16
+
17
+ private
18
+ def freeze_component(value)
19
+ (value || []).freeze
20
+ end
21
+
22
+ public
23
+
24
+ def ingredients_hash
25
+ @ingredients.each_with_object({}) do |ingredient, hash|
26
+ hash[ingredient.name] = {
27
+ quantity: ingredient.quantity,
28
+ unit: ingredient.unit
29
+ }.compact
30
+ end
31
+ end
32
+
33
+ def steps_text
34
+ @steps.map(&:to_text)
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ ingredients: @ingredients.map(&:to_h),
40
+ cookware: @cookware.map(&:to_h),
41
+ timers: @timers.map(&:to_h),
42
+ steps: @steps.map(&:to_h),
43
+ metadata: @metadata.to_h,
44
+ sections: @sections.map(&:to_h),
45
+ notes: @notes.map(&:to_h)
46
+ }
47
+ end
48
+
49
+ def ==(other)
50
+ return false unless other.is_a?(Recipe)
51
+
52
+ comparable_attributes.all? do |attr|
53
+ send(attr) == other.send(attr)
54
+ end
55
+ end
56
+
57
+ private
58
+ def comparable_attributes
59
+ %i[ingredients cookware timers steps metadata sections notes]
60
+ end
61
+
62
+ public
63
+
64
+ def eql?(other)
65
+ self == other
66
+ end
67
+
68
+ def hash
69
+ [ingredients, cookware, timers, steps, metadata].hash
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ class Section
5
+ attr_reader :name, :steps
6
+
7
+ def initialize(name:, steps: [])
8
+ @name = name&.to_s&.freeze
9
+ @steps = steps.freeze
10
+ end
11
+
12
+ def to_s
13
+ name || "Section"
14
+ end
15
+
16
+ def to_h
17
+ {
18
+ name: name,
19
+ steps: steps.map(&:to_h)
20
+ }.compact
21
+ end
22
+
23
+ def ==(other)
24
+ other.is_a?(Section) &&
25
+ name == other.name &&
26
+ steps == other.steps
27
+ end
28
+
29
+ def hash
30
+ [name, steps].hash
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ class Step
5
+ attr_reader :segments
6
+
7
+ def initialize(segments:)
8
+ @segments = segments.freeze
9
+ end
10
+
11
+ def to_text
12
+ text_parts = @segments.map do |segment|
13
+ case segment
14
+ when Hash
15
+ case segment[:type]
16
+ when :ingredient
17
+ segment[:name]
18
+ when :cookware
19
+ segment[:name]
20
+ when :timer
21
+ segment[:name] || "timer"
22
+ else
23
+ segment[:value] || ""
24
+ end
25
+ when String
26
+ segment
27
+ when Ingredient
28
+ segment.name
29
+ when Cookware
30
+ segment.name
31
+ when Timer
32
+ if segment.name
33
+ "#{segment.name} for #{segment.duration} #{segment.unit}"
34
+ elsif segment.duration && segment.unit
35
+ "for #{segment.duration} #{segment.unit}"
36
+ else
37
+ "timer"
38
+ end
39
+ else
40
+ segment.to_s
41
+ 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
53
+ end
54
+
55
+ def to_h
56
+ {
57
+ segments: @segments
58
+ }
59
+ end
60
+
61
+ def ==(other)
62
+ return false unless other.is_a?(Step)
63
+
64
+ segments == other.segments
65
+ end
66
+
67
+ def eql?(other)
68
+ self == other
69
+ end
70
+
71
+ def hash
72
+ segments.hash
73
+ end
74
+
75
+ def ingredients_used
76
+ @segments.filter_map { |segment| segment[:name] if segment.is_a?(Hash) && segment[:type] == :ingredient }
77
+ end
78
+
79
+ def cookware_used
80
+ @segments.filter_map { |segment| segment[:name] if segment.is_a?(Hash) && segment[:type] == :cookware }
81
+ end
82
+
83
+ def timers_used
84
+ @segments.select { |segment| segment.is_a?(Hash) && segment[:type] == :timer }
85
+ end
86
+
87
+ def has_ingredients?
88
+ ingredients_used.any?
89
+ end
90
+
91
+ def has_cookware?
92
+ cookware_used.any?
93
+ end
94
+
95
+ def has_timers?
96
+ timers_used.any?
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ class Timer
5
+ attr_reader :name, :duration, :unit
6
+
7
+ def initialize(duration:, unit:, name: nil)
8
+ @name = name&.to_s&.freeze
9
+ @duration = duration
10
+ @unit = unit&.to_s&.freeze
11
+ end
12
+
13
+ def to_s
14
+ result = ""
15
+ result += "#{@name}: " if @name
16
+ result += "#{@duration} #{@unit}"
17
+ result
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ name: @name,
23
+ duration: @duration,
24
+ unit: @unit
25
+ }.compact
26
+ end
27
+
28
+ def ==(other)
29
+ return false unless other.is_a?(Timer)
30
+
31
+ name == other.name &&
32
+ duration == other.duration &&
33
+ unit == other.unit
34
+ end
35
+
36
+ def eql?(other)
37
+ self == other
38
+ end
39
+
40
+ def hash
41
+ [name, duration, unit].hash
42
+ end
43
+
44
+ def total_seconds
45
+ return @duration unless @unit
46
+
47
+ case @unit.downcase
48
+ when "second", "seconds", "sec", "s"
49
+ @duration
50
+ when "minute", "minutes", "min", "m"
51
+ @duration * 60
52
+ when "hour", "hours", "hr", "h"
53
+ @duration * 3600
54
+ when "day", "days", "d"
55
+ @duration * 86_400
56
+ else
57
+ @duration
58
+ end
59
+ end
60
+
61
+ def has_name?
62
+ !@name.nil?
63
+ end
64
+ end
65
+ end
@@ -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 = "0.1.0"
4
+ VERSION = "1.0.1"
5
5
  end
data/lib/cooklang.rb CHANGED
@@ -1,8 +1,29 @@
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"
7
+ require_relative "cooklang/ingredient"
8
+ require_relative "cooklang/cookware"
9
+ require_relative "cooklang/timer"
10
+ require_relative "cooklang/step"
11
+ require_relative "cooklang/metadata"
12
+ require_relative "cooklang/section"
13
+ require_relative "cooklang/note"
14
+ require_relative "cooklang/formatter"
15
+ require_relative "cooklang/formatters/text"
4
16
 
5
17
  module Cooklang
6
18
  class Error < StandardError; end
7
- # Your code goes here...
19
+ class ParseError < Error; end
20
+
21
+ def self.parse(input)
22
+ parser = Parser.new
23
+ parser.parse(input)
24
+ end
25
+
26
+ def self.parse_file(file_path)
27
+ parse(File.read(file_path))
28
+ end
8
29
  end