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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cookware"
4
+ require_relative "../token_stream"
5
+
6
+ module Cooklang
7
+ module Parsers
8
+ class CookwareParser
9
+ def initialize(stream)
10
+ @stream = stream
11
+ end
12
+
13
+ def parse
14
+ return nil unless @stream.current&.type == :cookware_marker
15
+ @stream.consume(:cookware_marker) # Skip the # marker
16
+
17
+ return nil if invalid_syntax?
18
+
19
+ brace_index = find_next_brace
20
+ has_valid_brace = brace_index && !brace_belongs_to_other_marker?(brace_index)
21
+
22
+ if has_valid_brace
23
+ parse_braced_cookware(brace_index)
24
+ else
25
+ parse_simple_cookware
26
+ end
27
+ end
28
+
29
+ private
30
+ def invalid_syntax?
31
+ @stream.current&.type == :text && @stream.current.value.start_with?(" ")
32
+ end
33
+
34
+ def find_next_brace
35
+ @stream.find_next(:open_brace)
36
+ end
37
+
38
+ def brace_belongs_to_other_marker?(brace_index)
39
+ current_pos = @stream.position
40
+
41
+ while @stream.position < brace_index && !@stream.eof?
42
+ if %i[ingredient_marker cookware_marker timer_marker].include?(@stream.current.type)
43
+ @stream.advance_to(current_pos)
44
+ return true
45
+ end
46
+ @stream.consume
47
+ end
48
+
49
+ @stream.advance_to(current_pos)
50
+ false
51
+ end
52
+
53
+ def parse_braced_cookware(brace_index)
54
+ name = extract_name_until_brace(brace_index)
55
+ @stream.consume(:open_brace) # Skip open brace
56
+
57
+ quantity = extract_quantity
58
+ @stream.consume(:close_brace) # Skip close brace
59
+
60
+ quantity = 1 if quantity.nil? || quantity == ""
61
+
62
+ Cookware.new(name: name, quantity: quantity)
63
+ end
64
+
65
+ def parse_simple_cookware
66
+ remaining_text = nil
67
+
68
+ if @stream.current&.type == :text
69
+ text = @stream.current.value
70
+ if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
71
+ name = ::Regexp.last_match(1)
72
+ remaining_text = ::Regexp.last_match(2)
73
+ else
74
+ name = text.strip
75
+ end
76
+ @stream.consume
77
+ else
78
+ name = ""
79
+ end
80
+
81
+ cookware_item = Cookware.new(name: name, quantity: 1)
82
+ [cookware_item, remaining_text]
83
+ end
84
+
85
+ def extract_name_until_brace(brace_index)
86
+ name_parts = []
87
+
88
+ while @stream.position < brace_index && !@stream.eof?
89
+ case @stream.current.type
90
+ when :text, :hyphen
91
+ name_parts << @stream.current.value
92
+ end
93
+ @stream.consume
94
+ end
95
+
96
+ name_parts.join.strip
97
+ end
98
+
99
+ def extract_quantity
100
+ text_parts = []
101
+
102
+ while !@stream.eof? && @stream.current.type != :close_brace
103
+ case @stream.current.type
104
+ when :percent
105
+ # Skip percent in cookware - quantity comes after
106
+ @stream.consume
107
+ when :text
108
+ text_parts << @stream.current.value
109
+ @stream.consume
110
+ else
111
+ @stream.consume
112
+ end
113
+ end
114
+
115
+ return nil if text_parts.empty?
116
+
117
+ combined_text = text_parts.join.strip
118
+ parse_quantity_value(combined_text)
119
+ end
120
+
121
+ def parse_quantity_value(text)
122
+ case text
123
+ when /^\d+$/
124
+ text.to_i
125
+ when /^\d+\.\d+$/
126
+ text.to_f
127
+ else
128
+ text
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -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