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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Formatters
5
+ class Hash < Formatter
6
+ def generate(*args)
7
+ {
8
+ metadata: format_metadata,
9
+ sections: format_sections,
10
+ ingredients: format_ingredients,
11
+ cookware: format_cookware,
12
+ timers: format_timers,
13
+ inline_quantities: [],
14
+ data: format_data
15
+ }
16
+ end
17
+
18
+ private
19
+ def format_metadata
20
+ {
21
+ map: recipe.metadata.to_h.transform_values(&:to_s)
22
+ }
23
+ end
24
+
25
+ def format_sections
26
+ [
27
+ {
28
+ name: nil,
29
+ content: format_steps_as_content
30
+ }
31
+ ]
32
+ end
33
+
34
+ def format_steps_as_content
35
+ recipe.steps.each_with_index.map do |step, index|
36
+ {
37
+ type: "step",
38
+ value: {
39
+ items: format_step_items(step),
40
+ number: index + 1
41
+ }
42
+ }
43
+ end
44
+ end
45
+
46
+ def format_step_items(step)
47
+ items = []
48
+
49
+ step.segments.each do |segment|
50
+ items << format_segment(segment)
51
+ end
52
+
53
+ items
54
+ end
55
+
56
+ def format_segment(segment)
57
+ case segment
58
+ when Hash
59
+ case segment[:type]
60
+ when :ingredient
61
+ {
62
+ type: "ingredient",
63
+ index: find_ingredient_index(segment[:name])
64
+ }
65
+ when :cookware
66
+ {
67
+ type: "cookware",
68
+ index: find_cookware_index(segment[:name])
69
+ }
70
+ when :timer
71
+ {
72
+ type: "timer",
73
+ index: find_timer_index(segment)
74
+ }
75
+ else
76
+ {
77
+ type: "text",
78
+ value: segment[:value] || ""
79
+ }
80
+ end
81
+ when String
82
+ {
83
+ type: "text",
84
+ value: segment
85
+ }
86
+ when Cooklang::Ingredient
87
+ {
88
+ type: "ingredient",
89
+ index: find_ingredient_index(segment.name)
90
+ }
91
+ when Cooklang::Cookware
92
+ {
93
+ type: "cookware",
94
+ index: find_cookware_index(segment.name)
95
+ }
96
+ when Cooklang::Timer
97
+ {
98
+ type: "timer",
99
+ index: find_timer_index(segment)
100
+ }
101
+ else
102
+ {
103
+ type: "text",
104
+ value: segment.to_s
105
+ }
106
+ end
107
+ end
108
+
109
+ def format_ingredients
110
+ recipe.ingredients.map do |ingredient|
111
+ {
112
+ name: ingredient.name,
113
+ alias: nil,
114
+ quantity: format_ingredient_quantity(ingredient),
115
+ note: ingredient.notes,
116
+ reference: nil,
117
+ relation: {
118
+ type: "definition",
119
+ referenced_from: [],
120
+ defined_in_step: true,
121
+ reference_target: nil
122
+ },
123
+ modifiers: ""
124
+ }
125
+ end
126
+ end
127
+
128
+ def format_ingredient_quantity(ingredient)
129
+ return nil unless ingredient.quantity && ingredient.quantity != "some"
130
+
131
+ {
132
+ value: {
133
+ type: "number",
134
+ value: {
135
+ type: "regular",
136
+ value: ingredient.quantity.to_f
137
+ }
138
+ },
139
+ unit: ingredient.unit
140
+ }
141
+ end
142
+
143
+ def format_cookware
144
+ recipe.cookware.map do |cookware_item|
145
+ {
146
+ name: cookware_item.name,
147
+ alias: nil,
148
+ quantity: format_cookware_quantity(cookware_item),
149
+ note: nil,
150
+ relation: {
151
+ type: "definition",
152
+ referenced_from: [],
153
+ defined_in_step: true
154
+ },
155
+ modifiers: ""
156
+ }
157
+ end
158
+ end
159
+
160
+ def format_cookware_quantity(cookware_item)
161
+ return nil unless cookware_item.quantity && cookware_item.quantity != 1
162
+
163
+ {
164
+ value: {
165
+ type: "number",
166
+ value: {
167
+ type: "regular",
168
+ value: cookware_item.quantity.to_f
169
+ }
170
+ },
171
+ unit: nil
172
+ }
173
+ end
174
+
175
+ def format_timers
176
+ recipe.timers.map do |timer|
177
+ {
178
+ name: (timer.name.nil? || timer.name.empty?) ? nil : timer.name,
179
+ quantity: format_timer_quantity(timer)
180
+ }
181
+ end
182
+ end
183
+
184
+ def format_timer_quantity(timer)
185
+ return nil unless timer.duration
186
+
187
+ {
188
+ value: {
189
+ type: "number",
190
+ value: {
191
+ type: "regular",
192
+ value: timer.duration.to_f
193
+ }
194
+ },
195
+ unit: timer.unit
196
+ }
197
+ end
198
+
199
+ def find_ingredient_index(name)
200
+ recipe.ingredients.find_index { |ingredient| ingredient.name == name } || 0
201
+ end
202
+
203
+ def find_cookware_index(name)
204
+ recipe.cookware.find_index { |cookware_item| cookware_item.name == name } || 0
205
+ end
206
+
207
+ def find_timer_index(timer_data)
208
+ # For hash segments, we need to match by duration and unit
209
+ if timer_data.is_a?(Hash)
210
+ recipe.timers.find_index do |timer|
211
+ timer.duration == timer_data[:duration] && timer.unit == timer_data[:unit]
212
+ end || 0
213
+ elsif timer_data.is_a?(Cooklang::Timer)
214
+ recipe.timers.find_index { |timer| timer == timer_data } || 0
215
+ else
216
+ 0
217
+ end
218
+ end
219
+
220
+ def format_data
221
+ {
222
+ type: "Scaled",
223
+ target: {
224
+ factor: 1.0
225
+ },
226
+ ingredients: format_ingredient_data,
227
+ cookware: format_cookware_data,
228
+ timers: format_timer_data
229
+ }
230
+ end
231
+
232
+ def format_ingredient_data
233
+ recipe.ingredients.map do |ingredient|
234
+ if ingredient.quantity && ingredient.quantity != "some"
235
+ "scaled"
236
+ else
237
+ "noQuantity"
238
+ end
239
+ end
240
+ end
241
+
242
+ def format_cookware_data
243
+ recipe.cookware.map do |cookware_item|
244
+ if cookware_item.quantity && cookware_item.quantity != 1
245
+ "scaled"
246
+ else
247
+ "noQuantity"
248
+ end
249
+ end
250
+ end
251
+
252
+ def format_timer_data
253
+ recipe.timers.map { "fixed" }
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Cooklang
6
+ module Formatters
7
+ class Json < Hash
8
+ def generate(*args)
9
+ super().to_json(*args)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../formatter"
4
-
5
3
  module Cooklang
6
4
  module Formatters
7
5
  class Text < Formatter
8
- def to_s
6
+ def generate(*args)
9
7
  sections = []
10
8
 
11
9
  sections << ingredients_section unless recipe.ingredients.empty?
@@ -42,27 +42,17 @@ module Cooklang
42
42
 
43
43
  until @scanner.eos?
44
44
  if match_yaml_delimiter
45
- # Handle YAML front matter
46
45
  elsif match_comment_block
47
- # Handle block comments
48
46
  elsif match_comment_line
49
- # Handle line comments
50
47
  elsif match_metadata_marker
51
- # Handle >> metadata
52
48
  elsif match_section_marker
53
- # Handle = section markers
54
49
  elsif match_note_marker
55
- # Handle > note markers (only if not part of >>)
56
50
  elsif match_special_chars
57
- # Handle single character tokens
58
51
  elsif match_newline
59
- # Handle newlines
60
52
  elsif match_text
61
- # Handle plain text
62
53
  elsif match_hyphen
63
- # Handle single hyphens that aren't part of comments
64
54
  else
65
- # Skip unrecognized characters
55
+ # Skip unrecognized character
66
56
  advance_position(@scanner.getch)
67
57
  end
68
58
  end
@@ -258,14 +248,15 @@ module Cooklang
258
248
  end
259
249
 
260
250
  def match_text
261
- # Match any text that's not a special character, including spaces and tabs
251
+ # Match any printable text that's not a special character, including spaces and tabs
262
252
  # Exclude [ and ] to allow block comment detection
263
253
  # Exclude = and > to allow section and note markers
264
- if @scanner.check(/[^@#~{}()%\n\-\[\]=>]+/)
254
+ # Include tabs explicitly along with printable characters
255
+ if @scanner.check(/[\t[:print:]&&[^@#~{}()%\n\-\[\]=>]]+/)
265
256
  position = current_position
266
257
  line = current_line
267
258
  column = current_column
268
- text = @scanner.scan(/[^@#~{}()%\n\-\[\]=>]+/)
259
+ text = @scanner.scan(/[\t[:print:]&&[^@#~{}()%\n\-\[\]=>]]+/)
269
260
  @tokens << Token.new(:text, text, position, line, column)
270
261
  advance_position(text)
271
262
  true