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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Parsers
5
+ class CookwareParser
6
+ def initialize(stream)
7
+ @stream = stream
8
+ end
9
+
10
+ def parse
11
+ return nil unless @stream.current&.type == :cookware_marker
12
+ @stream.consume(:cookware_marker) # Skip the # marker
13
+
14
+ return nil if invalid_syntax?
15
+
16
+ brace_index = find_next_brace
17
+ has_valid_brace = brace_index && !brace_belongs_to_other_marker?(brace_index)
18
+
19
+ if has_valid_brace
20
+ parse_braced_cookware(brace_index)
21
+ else
22
+ parse_simple_cookware
23
+ end
24
+ end
25
+
26
+ private
27
+ def invalid_syntax?
28
+ @stream.current&.type == :text && @stream.current.value.start_with?(" ")
29
+ end
30
+
31
+ def find_next_brace
32
+ @stream.find_next(:open_brace)
33
+ end
34
+
35
+ def brace_belongs_to_other_marker?(brace_index)
36
+ current_pos = @stream.position
37
+
38
+ while @stream.position < brace_index && !@stream.eof?
39
+ if %i[ingredient_marker cookware_marker timer_marker].include?(@stream.current.type)
40
+ @stream.advance_to(current_pos)
41
+ return true
42
+ end
43
+ @stream.consume
44
+ end
45
+
46
+ @stream.advance_to(current_pos)
47
+ false
48
+ end
49
+
50
+ def parse_braced_cookware(brace_index)
51
+ name = extract_name_until_brace(brace_index)
52
+ @stream.consume(:open_brace) # Skip open brace
53
+
54
+ quantity = extract_quantity
55
+ @stream.consume(:close_brace) # Skip close brace
56
+
57
+ quantity = 1 if quantity.nil? || quantity == ""
58
+
59
+ Cookware.new(name: name, quantity: quantity)
60
+ end
61
+
62
+ def parse_simple_cookware
63
+ remaining_text = nil
64
+
65
+ if @stream.current&.type == :text
66
+ text = @stream.current.value
67
+ if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
68
+ name = ::Regexp.last_match(1)
69
+ remaining_text = ::Regexp.last_match(2)
70
+ else
71
+ name = text.strip
72
+ end
73
+ @stream.consume
74
+ else
75
+ name = ""
76
+ end
77
+
78
+ cookware_item = Cookware.new(name: name, quantity: 1)
79
+ [cookware_item, remaining_text]
80
+ end
81
+
82
+ def extract_name_until_brace(brace_index)
83
+ name_parts = []
84
+
85
+ while @stream.position < brace_index && !@stream.eof?
86
+ case @stream.current.type
87
+ when :text, :hyphen
88
+ name_parts << @stream.current.value
89
+ end
90
+ @stream.consume
91
+ end
92
+
93
+ name_parts.join.strip
94
+ end
95
+
96
+ def extract_quantity
97
+ text_parts = []
98
+
99
+ while !@stream.eof? && @stream.current.type != :close_brace
100
+ case @stream.current.type
101
+ when :percent
102
+ # Skip percent in cookware - quantity comes after
103
+ @stream.consume
104
+ when :text
105
+ text_parts << @stream.current.value
106
+ @stream.consume
107
+ else
108
+ @stream.consume
109
+ end
110
+ end
111
+
112
+ return nil if text_parts.empty?
113
+
114
+ combined_text = text_parts.join.strip
115
+ parse_quantity_value(combined_text)
116
+ end
117
+
118
+ def parse_quantity_value(text)
119
+ case text
120
+ when /^\d+$/
121
+ text.to_i
122
+ when /^\d+\.\d+$/
123
+ text.to_f
124
+ else
125
+ text
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Parsers
5
+ class IngredientParser
6
+ def initialize(stream)
7
+ @stream = stream
8
+ end
9
+
10
+ def parse
11
+ return nil unless @stream.current&.type == :ingredient_marker
12
+ @stream.consume(:ingredient_marker) # Skip the @ marker
13
+
14
+ return nil if invalid_syntax?
15
+
16
+ brace_index = find_next_brace
17
+
18
+ if brace_index
19
+ parse_braced_ingredient(brace_index)
20
+ else
21
+ parse_simple_ingredient
22
+ end
23
+ end
24
+
25
+ private
26
+ def invalid_syntax?
27
+ @stream.current&.type == :text && @stream.current.value.start_with?(" ")
28
+ end
29
+
30
+ def find_next_brace
31
+ @stream.find_next(:open_brace)
32
+ end
33
+
34
+ def parse_braced_ingredient(brace_index)
35
+ name = extract_name_until_brace(brace_index)
36
+ @stream.consume(:open_brace) # Skip open brace
37
+
38
+ quantity, unit = extract_quantity_and_unit
39
+ @stream.consume(:close_brace) # Skip close brace
40
+
41
+ notes = extract_notes
42
+
43
+ # Default values according to original logic
44
+ quantity = "some" if (quantity.nil? || quantity == "") && (unit.nil? || unit == "")
45
+ unit = nil if unit == ""
46
+
47
+ Ingredient.new(name: name, quantity: quantity, unit: unit, notes: notes)
48
+ end
49
+
50
+ def parse_simple_ingredient
51
+ remaining_text = nil
52
+
53
+ if @stream.current&.type == :text
54
+ text = @stream.current.value
55
+ if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
56
+ name = ::Regexp.last_match(1)
57
+ remaining_text = ::Regexp.last_match(2)
58
+ else
59
+ name = text.strip
60
+ end
61
+ @stream.consume
62
+ else
63
+ name = ""
64
+ end
65
+
66
+ ingredient = Ingredient.new(name: name, quantity: "some", unit: nil, notes: nil)
67
+ [ingredient, remaining_text]
68
+ end
69
+
70
+ def extract_name_until_brace(brace_index)
71
+ name_parts = []
72
+ @stream.position
73
+
74
+ while @stream.position < brace_index && !@stream.eof?
75
+ case @stream.current.type
76
+ when :text, :hyphen
77
+ name_parts << @stream.current.value
78
+ end
79
+ @stream.consume
80
+ end
81
+
82
+ name_parts.join.strip
83
+ end
84
+
85
+ def extract_quantity_and_unit
86
+ quantity = nil
87
+ unit = nil
88
+ text_parts = []
89
+
90
+ while !@stream.eof? && @stream.current.type != :close_brace
91
+ case @stream.current.type
92
+ when :percent
93
+ if !text_parts.empty?
94
+ quantity_text = text_parts.join.strip
95
+ quantity = parse_quantity_value(quantity_text)
96
+ end
97
+ text_parts = []
98
+ @stream.consume
99
+ when :text
100
+ text_parts << @stream.current.value
101
+ @stream.consume
102
+ else
103
+ @stream.consume
104
+ end
105
+ end
106
+
107
+ if !text_parts.empty?
108
+ combined_text = text_parts.join.strip
109
+ if quantity.nil?
110
+ # Try to parse as numeric quantity first, fallback to string if not purely numeric
111
+ if combined_text.match?(/^\d+$/)
112
+ quantity = combined_text.to_i
113
+ unit = ""
114
+ elsif combined_text.match?(/^\d+\.\d+$/)
115
+ quantity = combined_text.to_f
116
+ unit = ""
117
+ else
118
+ # Non-numeric content, keep as string quantity
119
+ quantity = combined_text
120
+ unit = ""
121
+ end
122
+ else
123
+ unit = combined_text
124
+ end
125
+ end
126
+
127
+ [quantity, unit]
128
+ end
129
+
130
+ def parse_quantity_value(text)
131
+ case text
132
+ when /^\d+$/
133
+ text.to_i
134
+ when /^\d+\.\d+$/
135
+ text.to_f
136
+ else
137
+ text
138
+ end
139
+ end
140
+
141
+ def extract_quantity_from_text(text)
142
+ # Simple quantity extraction - this could use QuantityExtractor if needed
143
+ if text.match(/^(\d+(?:\.\d+)?)\s*(.*)$/)
144
+ quantity_val = ::Regexp.last_match(1)
145
+ rest = ::Regexp.last_match(2)
146
+
147
+ quantity = quantity_val.include?(".") ? quantity_val.to_f : quantity_val.to_i
148
+ unit = rest.empty? ? nil : rest
149
+
150
+ [quantity, unit]
151
+ else
152
+ [text, nil]
153
+ end
154
+ end
155
+
156
+ def extract_notes
157
+ return nil unless @stream.current&.type == :open_paren
158
+
159
+ @stream.consume(:open_paren) # Skip open paren
160
+ notes_parts = []
161
+
162
+ while !@stream.eof? && @stream.current.type != :close_paren
163
+ if @stream.current.type == :text
164
+ notes_parts << @stream.current.value
165
+ end
166
+ @stream.consume
167
+ end
168
+
169
+ @stream.consume(:close_paren) if @stream.current&.type == :close_paren
170
+
171
+ notes = notes_parts.join.strip
172
+ notes.empty? ? nil : notes
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Parsers
5
+ class TimerParser
6
+ def initialize(stream)
7
+ @stream = stream
8
+ end
9
+
10
+ def parse
11
+ return nil unless @stream.current&.type == :timer_marker
12
+ @stream.consume(:timer_marker) # Skip the ~ marker
13
+
14
+ return nil if invalid_syntax?
15
+
16
+ brace_index = find_next_brace
17
+
18
+ if brace_index
19
+ parse_named_timer(brace_index)
20
+ else
21
+ parse_simple_timer
22
+ end
23
+ end
24
+
25
+ private
26
+ def invalid_syntax?
27
+ @stream.current&.type == :text && @stream.current.value.start_with?(" ")
28
+ end
29
+
30
+ def find_next_brace
31
+ @stream.find_next(:open_brace)
32
+ end
33
+
34
+ def parse_named_timer(brace_index)
35
+ name = nil
36
+
37
+ # Extract name if there's text before the brace
38
+ if @stream.position < brace_index && @stream.current&.type == :text
39
+ name = @stream.current.value.strip
40
+ @stream.advance_to(brace_index)
41
+ end
42
+
43
+ @stream.consume(:open_brace) # Skip open brace
44
+
45
+ duration, unit = extract_duration
46
+ @stream.consume(:close_brace) # Skip close brace
47
+
48
+ Timer.new(name: name, duration: duration, unit: unit)
49
+ end
50
+
51
+ def parse_simple_timer
52
+ remaining_text = nil
53
+ name = nil
54
+
55
+ if @stream.current&.type == :text
56
+ text = @stream.current.value
57
+ if text.match(/^([a-zA-Z0-9_]+)(.*)$/)
58
+ name = ::Regexp.last_match(1)
59
+ remaining_text = ::Regexp.last_match(2)
60
+ else
61
+ name = text.strip
62
+ end
63
+ @stream.consume
64
+ end
65
+
66
+ timer = Timer.new(name: name, duration: nil, unit: nil)
67
+ [timer, remaining_text]
68
+ end
69
+
70
+ def extract_duration
71
+ duration_parts = []
72
+ unit = nil
73
+
74
+ while !@stream.eof? && @stream.current.type != :close_brace
75
+ case @stream.current.type
76
+ when :text, :hyphen
77
+ duration_parts << @stream.current.value
78
+ @stream.consume
79
+ when :percent
80
+ @stream.consume
81
+ # Unit comes after percent
82
+ if @stream.current&.type == :text
83
+ unit = @stream.current.value
84
+ @stream.consume
85
+ end
86
+ else
87
+ @stream.consume
88
+ end
89
+ end
90
+
91
+ return [nil, nil] if duration_parts.empty?
92
+
93
+ duration_text = duration_parts.join
94
+
95
+ if unit
96
+ duration = parse_duration_value(duration_text)
97
+ [duration, unit]
98
+ else
99
+ parse_duration_with_unit(duration_text)
100
+ end
101
+ end
102
+
103
+ def parse_duration_value(text)
104
+ case text
105
+ when /^\d+$/
106
+ text.to_i
107
+ when /^\d+\.\d+$/
108
+ text.to_f
109
+ else
110
+ text # Keep ranges like "2-3"
111
+ end
112
+ end
113
+
114
+ def parse_duration_with_unit(text)
115
+ # Simple duration parsing - could be enhanced with DurationExtractor
116
+ # First check if it's just a number (integer or decimal)
117
+ if text.match?(/^(\d+(?:\.\d+)?)$/)
118
+ parsed_duration = text.include?(".") ? text.to_f : text.to_i
119
+ [parsed_duration, nil]
120
+ elsif text.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)$/)
121
+ duration = ::Regexp.last_match(1)
122
+ unit = ::Regexp.last_match(2)
123
+
124
+ parsed_duration = duration.include?(".") ? duration.to_f : duration.to_i
125
+ [parsed_duration, unit]
126
+ else
127
+ [text, nil]
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Processors
5
+ class ElementParser
6
+ class << self
7
+ def parse_ingredient(tokens, start_index)
8
+ parse_with_parser(tokens, start_index, Parsers::IngredientParser)
9
+ end
10
+
11
+ def parse_cookware(tokens, start_index)
12
+ parse_with_parser(tokens, start_index, Parsers::CookwareParser)
13
+ end
14
+
15
+ def parse_timer(tokens, start_index)
16
+ parse_with_parser(tokens, start_index, Parsers::TimerParser)
17
+ end
18
+
19
+ private
20
+ def parse_with_parser(tokens, start_index, parser_class)
21
+ stream = TokenStream.new(tokens)
22
+ stream.advance_to(start_index)
23
+
24
+ parser = parser_class.new(stream)
25
+ result = parser.parse
26
+ consumed = stream.position - start_index
27
+
28
+ # Handle the return format
29
+ if result.is_a?(Array)
30
+ [result[0], consumed, result[1]]
31
+ elsif result
32
+ [result, consumed, nil]
33
+ else
34
+ [nil, 1, nil]
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Processors
5
+ class MetadataProcessor
6
+ class << self
7
+ def extract_metadata(tokens)
8
+ metadata = Metadata.new
9
+ content_tokens = []
10
+ 0
11
+
12
+ # Check for YAML front matter
13
+ if tokens.first&.type == :yaml_delimiter
14
+ metadata, content_tokens, _ = extract_yaml_frontmatter(tokens)
15
+ else
16
+ content_tokens = tokens.dup
17
+ end
18
+
19
+ # Extract inline metadata
20
+ extract_inline_metadata(content_tokens, metadata)
21
+
22
+ [metadata, content_tokens]
23
+ end
24
+
25
+ private
26
+ def extract_yaml_frontmatter(tokens)
27
+ metadata = Metadata.new
28
+ i = 1 # Skip the first ---
29
+ yaml_content = []
30
+
31
+ # Find the closing --- delimiter
32
+ while i < tokens.length
33
+ if tokens[i].type == :yaml_delimiter
34
+ # Found closing delimiter
35
+ break
36
+ elsif tokens[i].type == :text
37
+ yaml_content << tokens[i].value
38
+ elsif tokens[i].type == :newline
39
+ yaml_content << "\n"
40
+ elsif tokens[i].type == :metadata_marker
41
+ yaml_content << ">>"
42
+ end
43
+ i += 1
44
+ end
45
+
46
+ # Skip the closing --- if we found it
47
+ i += 1 if i < tokens.length && tokens[i].type == :yaml_delimiter
48
+
49
+ # Parse YAML content if we have any
50
+ if yaml_content.any?
51
+ yaml_text = yaml_content.join.strip
52
+ parse_yaml_content(yaml_text, metadata) unless yaml_text.empty?
53
+ end
54
+
55
+ # Return remaining tokens
56
+ remaining_tokens = tokens[i..]
57
+
58
+ [metadata, remaining_tokens, i]
59
+ end
60
+
61
+ def parse_yaml_content(yaml_text, metadata)
62
+ # Simple YAML parsing - split by lines and parse key-value pairs
63
+ yaml_text.split("\n").each do |line|
64
+ line = line.strip
65
+ next if line.empty? || line.start_with?("#")
66
+
67
+ if line.match(/^([^:]+):\s*(.*)$/)
68
+ key = ::Regexp.last_match(1).strip
69
+ value = ::Regexp.last_match(2).strip
70
+
71
+ # Remove quotes if present
72
+ value = value.gsub(/^["']|["']$/, "") if value.match?(/^["'].*["']$/)
73
+
74
+ # Parse numeric values
75
+ parsed_value = parse_metadata_value(value)
76
+ metadata[key] = parsed_value unless value.empty?
77
+ end
78
+ end
79
+ end
80
+
81
+ def extract_inline_metadata(tokens, metadata)
82
+ tokens_to_remove = []
83
+
84
+ tokens.each_with_index do |token, index|
85
+ next unless token.type == :metadata_marker
86
+
87
+ # Look for metadata pattern: >> key: value
88
+ if index + 1 < tokens.length && tokens[index + 1].type == :text
89
+ text = tokens[index + 1].value.strip
90
+
91
+ if text.match(/^([^:]+):\s*(.+)$/)
92
+ key = ::Regexp.last_match(1).strip
93
+ value = ::Regexp.last_match(2).strip
94
+
95
+ # Remove quotes if present
96
+ value = value.gsub(/^["']|["']$/, "") if value.match?(/^["'].*["']$/)
97
+
98
+ # Parse numeric values
99
+ parsed_value = parse_metadata_value(value)
100
+ metadata[key] = parsed_value
101
+
102
+ # Mark both marker and text tokens for removal
103
+ tokens_to_remove << index << (index + 1)
104
+ end
105
+ end
106
+ end
107
+
108
+ # Remove only the specific metadata tokens that were processed
109
+ tokens.reject!.with_index { |token, index| tokens_to_remove.include?(index) }
110
+ end
111
+
112
+ def parse_metadata_value(value)
113
+ return value if value.empty?
114
+
115
+ # Try to parse as integer
116
+ return value.to_i if value.match?(/^\d+$/)
117
+
118
+ # Try to parse as float
119
+ return value.to_f if value.match?(/^\d+\.\d+$/)
120
+
121
+ # Return as string
122
+ value
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end