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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Cooklang
6
+ Token = Struct.new(:type, :value, :position, :line, :column) do
7
+ def initialize(type, value, position = 0, line = 1, column = 1)
8
+ super
9
+ end
10
+ end
11
+
12
+ class Lexer
13
+ TOKENS = {
14
+ ingredient_marker: "@",
15
+ cookware_marker: "#",
16
+ timer_marker: "~",
17
+ open_brace: "{",
18
+ close_brace: "}",
19
+ open_paren: "(",
20
+ close_paren: ")",
21
+ percent: "%",
22
+ comment_line: "--",
23
+ comment_block_start: "[-",
24
+ comment_block_end: "-]",
25
+ metadata_marker: ">>",
26
+ section_marker: "=",
27
+ note_marker: ">",
28
+ newline: "\n",
29
+ yaml_delimiter: "---"
30
+ }.freeze
31
+
32
+ def initialize(input)
33
+ @input = input
34
+ @scanner = StringScanner.new(input)
35
+ @line = 1
36
+ @column = 1
37
+ @tokens = []
38
+ end
39
+
40
+ def tokenize
41
+ @tokens = []
42
+
43
+ until @scanner.eos?
44
+ if match_yaml_delimiter
45
+ elsif match_comment_block
46
+ elsif match_comment_line
47
+ elsif match_metadata_marker
48
+ elsif match_section_marker
49
+ elsif match_note_marker
50
+ elsif match_special_chars
51
+ elsif match_newline
52
+ elsif match_text
53
+ elsif match_hyphen
54
+ else
55
+ # Skip unrecognized character
56
+ advance_position(@scanner.getch)
57
+ end
58
+ end
59
+
60
+ @tokens
61
+ end
62
+
63
+ private
64
+ def current_position
65
+ @scanner.pos
66
+ end
67
+
68
+ def current_line
69
+ @line
70
+ end
71
+
72
+ def current_column
73
+ @column
74
+ end
75
+
76
+ def advance_position(text)
77
+ text.each_char do |char|
78
+ if char == "\n"
79
+ @line += 1
80
+ @column = 1
81
+ else
82
+ @column += 1
83
+ end
84
+ end
85
+ end
86
+
87
+ def add_token(type, value)
88
+ position = current_position
89
+ line = current_line
90
+ column = current_column
91
+ @tokens << Token.new(type, value, position, line, column)
92
+ advance_position(value)
93
+ end
94
+
95
+ def capture_single_char_token(type)
96
+ position = current_position
97
+ line = current_line
98
+ column = current_column
99
+ value = @scanner.getch
100
+ @tokens << Token.new(type, value, position, line, column)
101
+ advance_position(value)
102
+ true
103
+ end
104
+
105
+ def match_yaml_delimiter
106
+ if @scanner.check(/^---/)
107
+ position = current_position
108
+ line = current_line
109
+ column = current_column
110
+ value = @scanner.scan("---")
111
+ @tokens << Token.new(:yaml_delimiter, value, position, line, column)
112
+ advance_position(value)
113
+ true
114
+ else
115
+ false
116
+ end
117
+ end
118
+
119
+ def match_comment_block
120
+ if @scanner.check(/\[-/)
121
+ position = current_position
122
+ line = current_line
123
+ column = current_column
124
+ @scanner.scan("[-")
125
+ @tokens << Token.new(:comment_block_start, "[-", position, line, column)
126
+ advance_position("[-")
127
+
128
+ # Scan until block end or EOF
129
+ content = ""
130
+ content += @scanner.getch while !@scanner.eos? && !@scanner.check(/-\]/)
131
+
132
+ add_token(:text, content) unless content.empty?
133
+
134
+ if @scanner.check(/-\]/)
135
+ position = current_position
136
+ line = current_line
137
+ column = current_column
138
+ @scanner.scan("-]")
139
+ @tokens << Token.new(:comment_block_end, "-]", position, line, column)
140
+ advance_position("-]")
141
+ end
142
+
143
+ true
144
+ else
145
+ false
146
+ end
147
+ end
148
+
149
+ def match_comment_line
150
+ if @scanner.check(/--/)
151
+ position = current_position
152
+ line = current_line
153
+ column = current_column
154
+ value = @scanner.scan("--")
155
+ @tokens << Token.new(:comment_line, value, position, line, column)
156
+ advance_position(value)
157
+
158
+ # Scan rest of line
159
+ line_content = @scanner.scan(/[^\n]*/)
160
+ add_token(:text, line_content) if line_content && !line_content.empty?
161
+
162
+ true
163
+ else
164
+ false
165
+ end
166
+ end
167
+
168
+ def match_metadata_marker
169
+ if @scanner.check(/>>/)
170
+ position = current_position
171
+ line = current_line
172
+ column = current_column
173
+ value = @scanner.scan(">>")
174
+ @tokens << Token.new(:metadata_marker, value, position, line, column)
175
+ advance_position(value)
176
+ true
177
+ else
178
+ false
179
+ end
180
+ end
181
+
182
+ def match_section_marker
183
+ if @scanner.check(/=+/)
184
+ position = current_position
185
+ line = current_line
186
+ column = current_column
187
+ value = @scanner.scan(/=+/)
188
+ @tokens << Token.new(:section_marker, value, position, line, column)
189
+ advance_position(value)
190
+ true
191
+ else
192
+ false
193
+ end
194
+ end
195
+
196
+ def match_note_marker
197
+ # Only match > if it's not part of >>
198
+ if @scanner.check(/>/) && !@scanner.check(/>>/)
199
+ position = current_position
200
+ line = current_line
201
+ column = current_column
202
+ value = @scanner.scan(">")
203
+ @tokens << Token.new(:note_marker, value, position, line, column)
204
+ advance_position(value)
205
+ true
206
+ else
207
+ false
208
+ end
209
+ end
210
+
211
+ def match_special_chars
212
+ char = @scanner.check(/./)
213
+
214
+ case char
215
+ when "@"
216
+ capture_single_char_token(:ingredient_marker)
217
+ when "#"
218
+ capture_single_char_token(:cookware_marker)
219
+ when "~"
220
+ capture_single_char_token(:timer_marker)
221
+ when "{"
222
+ capture_single_char_token(:open_brace)
223
+ when "}"
224
+ capture_single_char_token(:close_brace)
225
+ when "("
226
+ capture_single_char_token(:open_paren)
227
+ when ")"
228
+ capture_single_char_token(:close_paren)
229
+ when "%"
230
+ capture_single_char_token(:percent)
231
+ else
232
+ false
233
+ end
234
+ end
235
+
236
+ def match_newline
237
+ if @scanner.check(/\n/)
238
+ position = current_position
239
+ line = current_line
240
+ column = current_column
241
+ value = @scanner.scan("\n")
242
+ @tokens << Token.new(:newline, value, position, line, column)
243
+ advance_position(value)
244
+ true
245
+ else
246
+ false
247
+ end
248
+ end
249
+
250
+ def match_text
251
+ # Match any printable text that's not a special character, including spaces and tabs
252
+ # Exclude [ and ] to allow block comment detection
253
+ # Exclude = and > to allow section and note markers
254
+ # Include tabs explicitly along with printable characters
255
+ if @scanner.check(/[\t[:print:]&&[^@#~{}()%\n\-\[\]=>]]+/)
256
+ position = current_position
257
+ line = current_line
258
+ column = current_column
259
+ text = @scanner.scan(/[\t[:print:]&&[^@#~{}()%\n\-\[\]=>]]+/)
260
+ @tokens << Token.new(:text, text, position, line, column)
261
+ advance_position(text)
262
+ true
263
+ else
264
+ false
265
+ end
266
+ end
267
+
268
+ def match_hyphen
269
+ if @scanner.check(/-/) && !@scanner.check(/--/)
270
+ position = current_position
271
+ line = current_line
272
+ column = current_column
273
+ @scanner.scan("-")
274
+ @tokens << Token.new(:hyphen, "-", position, line, column)
275
+ advance_position("-")
276
+ true
277
+ else
278
+ false
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ class Metadata < Hash
5
+ def initialize(data = {})
6
+ super()
7
+ data.each { |key, value| self[key.to_s] = value }
8
+ end
9
+
10
+ def []=(key, value)
11
+ super(key.to_s, value)
12
+ end
13
+
14
+ def [](key)
15
+ super(key.to_s)
16
+ end
17
+
18
+ def key?(key)
19
+ super(key.to_s)
20
+ end
21
+
22
+ def delete(key)
23
+ super(key.to_s)
24
+ end
25
+
26
+ def fetch(key, *)
27
+ super(key.to_s, *)
28
+ end
29
+
30
+ def to_h
31
+ super
32
+ end
33
+
34
+ def servings
35
+ self["servings"]&.to_i
36
+ end
37
+
38
+ def servings=(value)
39
+ self["servings"] = value
40
+ end
41
+
42
+ def prep_time
43
+ self["prep_time"] || self["prep-time"]
44
+ end
45
+
46
+ def prep_time=(value)
47
+ self["prep_time"] = value
48
+ end
49
+
50
+ def cook_time
51
+ self["cook_time"] || self["cook-time"]
52
+ end
53
+
54
+ def cook_time=(value)
55
+ self["cook_time"] = value
56
+ end
57
+
58
+ def total_time
59
+ self["total_time"] || self["total-time"]
60
+ end
61
+
62
+ def total_time=(value)
63
+ self["total_time"] = value
64
+ end
65
+
66
+ def title
67
+ self["title"]
68
+ end
69
+
70
+ def title=(value)
71
+ self["title"] = value
72
+ end
73
+
74
+ def source
75
+ self["source"]
76
+ end
77
+
78
+ def source=(value)
79
+ self["source"] = value
80
+ end
81
+
82
+ def tags
83
+ value = self["tags"]
84
+ case value
85
+ when Array
86
+ value
87
+ when String
88
+ value.split(",").map(&:strip)
89
+ else
90
+ []
91
+ end
92
+ end
93
+
94
+ def tags=(value)
95
+ self["tags"] = value
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ class Note
5
+ attr_reader :content
6
+
7
+ def initialize(content:)
8
+ @content = content.to_s.freeze
9
+ end
10
+
11
+ def to_s
12
+ content
13
+ end
14
+
15
+ def to_h
16
+ { content: content }
17
+ end
18
+
19
+ def ==(other)
20
+ other.is_a?(Note) && content == other.content
21
+ end
22
+
23
+ def hash
24
+ content.hash
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lexer"
4
+ require_relative "processors/metadata_processor"
5
+ require_relative "processors/token_processor"
6
+ require_relative "processors/step_processor"
7
+ require_relative "builders/recipe_builder"
8
+
9
+ module Cooklang
10
+ class Parser
11
+ def parse(input)
12
+ # Tokenize input
13
+ lexer = Lexer.new(input)
14
+ tokens = lexer.tokenize
15
+
16
+ # Extract metadata
17
+ metadata, content_tokens = Processors::MetadataProcessor.extract_metadata(tokens)
18
+
19
+ # Clean up tokens
20
+ cleaned_tokens = Processors::TokenProcessor.strip_comments(content_tokens)
21
+ notes, recipe_tokens = Processors::TokenProcessor.extract_notes(cleaned_tokens)
22
+
23
+ # Parse steps
24
+ parsed_steps = Processors::StepProcessor.parse_steps(recipe_tokens)
25
+
26
+ # Build final recipe
27
+ recipe = Builders::RecipeBuilder.build_recipe(parsed_steps, metadata)
28
+
29
+ # Add notes to recipe (create new recipe with notes)
30
+ Recipe.new(
31
+ ingredients: recipe.ingredients,
32
+ cookware: recipe.cookware,
33
+ timers: recipe.timers,
34
+ steps: recipe.steps,
35
+ metadata: recipe.metadata,
36
+ sections: recipe.sections,
37
+ notes: notes
38
+ )
39
+ end
40
+ end
41
+ end
@@ -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