cooklang 0.1.0 → 1.0.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6695e5e9c9b98ec1aa80cd6cda05909fd39008454e3667761516365874a28054
4
- data.tar.gz: 70d755aeed7e1ef4810d1a797a471740adaeddd5382a031abdf0e972a8f5a827
3
+ metadata.gz: 6e7756c185b6f5a4e57a8a1b018e9815db1c459f195eb70a9673979cd0e83d09
4
+ data.tar.gz: 7584f41ee1d0dc2639752e416a2b0d3627c234d0841de82a90bbabe4890c81a5
5
5
  SHA512:
6
- metadata.gz: 7799cc3b498d4ebb8f519af4c3dbf50ced4618afe29a724cbfb83702bf95f52360e1e390bbb186d101bdf0e2a05eca30fcf5915e6007ca0c29bfaeb0b7df0abd
7
- data.tar.gz: 04bafd2e069bbd3192ef617b15d3a71f3c951232053bf4a9b656d94c74814f9a46d59393ae11bc9a8ead3fc2b2758db61cd4b7e77aa75598dede89e51e5939dd
6
+ metadata.gz: 3cd10e971a12ec43d64d771dc63ca79f9b28af10675071348bed5b1f9889c7c5c21e135287cb2041a0bd998a05700572b72bd137b038a353140ca03c669c7d1d
7
+ data.tar.gz: d0aa1105e1d42bb36aec6ca1f07102851603a0c7abbace24c721813a341cd2e03455a27118c401c42ce97fb9082bfb849eb8824beddeea79e4f91411a33a7fcc
@@ -1,6 +1,6 @@
1
- MIT License
1
+ The MIT License (MIT)
2
2
 
3
- Copyright (c) 2022 James Brooks
3
+ Copyright (c) 2025 James Brooks
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
9
  copies of the Software, and to permit persons to whom the Software is
10
10
  furnished to do so, subject to the following conditions:
11
11
 
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
14
 
15
15
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
16
  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
17
  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md CHANGED
@@ -1,35 +1,124 @@
1
1
  # Cooklang
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/cooklang`. To experiment with that code, run `bin/console` for an interactive prompt.
4
-
5
- TODO: Delete this and the text above, and describe your gem
3
+ A Ruby parser for the [Cooklang](https://cooklang.org) recipe markup language.
6
4
 
7
5
  ## Installation
8
6
 
9
- Add this line to your application's Gemfile:
7
+ Add to your Gemfile:
10
8
 
11
9
  ```ruby
12
10
  gem 'cooklang'
13
11
  ```
14
12
 
15
- And then execute:
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install cooklang
17
+ ```
16
18
 
17
- $ bundle install
19
+ ## Usage
18
20
 
19
- Or install it yourself as:
21
+ ```ruby
22
+ require 'cooklang'
23
+
24
+ recipe_text = <<~RECIPE
25
+ >> title: Pancakes
26
+ >> servings: 4
27
+
28
+ Crack @eggs{3} into a bowl, add @flour{125%g} and @milk{250%ml}.
29
+
30
+ Heat #frying pan over medium heat for ~{5%minutes}.
31
+ Pour batter and cook until golden.
32
+ RECIPE
33
+
34
+ recipe = Cooklang.parse(recipe_text)
35
+
36
+ # Access metadata
37
+ recipe.metadata['title'] # => "Pancakes"
38
+ recipe.metadata['servings'] # => 4
39
+
40
+ # Access ingredients
41
+ recipe.ingredients.each do |ingredient|
42
+ puts "#{ingredient.name}: #{ingredient.quantity} #{ingredient.unit}"
43
+ end
44
+ # => eggs: 3
45
+ # => flour: 125 g
46
+ # => milk: 250 ml
47
+
48
+ # Parse from file
49
+ recipe = Cooklang.parse_file('pancakes.cook')
50
+
51
+ # Format as text
52
+ formatter = Cooklang::Formatters::Text.new(recipe)
53
+ puts formatter.to_s
54
+ # Ingredients:
55
+ # eggs 3
56
+ # flour 125 g
57
+ # milk 250 ml
58
+ #
59
+ # Steps:
60
+ # 1. Crack eggs into a bowl, add flour and milk.
61
+ # 2. Heat frying pan over medium heat for 5 minutes.
62
+ # 3. Pour batter and cook until golden.
63
+ ```
20
64
 
21
- $ gem install cooklang
65
+ ## API
22
66
 
23
- ## Usage
67
+ ```ruby
68
+ # Recipe object
69
+ recipe.metadata # Hash of metadata
70
+ recipe.ingredients # Array of Ingredient objects
71
+ recipe.cookware # Array of Cookware objects
72
+ recipe.timers # Array of Timer objects
73
+ recipe.steps # Array of Step objects
74
+ recipe.steps_text # Array of plain text steps
75
+
76
+ # Ingredient
77
+ ingredient.name # "flour"
78
+ ingredient.quantity # 125
79
+ ingredient.unit # "g"
80
+ ingredient.notes # "sifted"
81
+
82
+ # Cookware
83
+ cookware.name # "frying pan"
84
+ cookware.quantity # 1
85
+
86
+ # Timer
87
+ timer.name # "pasta"
88
+ timer.duration # 10
89
+ timer.unit # "minutes"
90
+ ```
91
+
92
+ ## Cooklang Syntax
24
93
 
25
- TODO: Write usage instructions here
94
+ - **Ingredients**: `@salt`, `@flour{125%g}`, `@onion{1}(diced)`
95
+ - **Cookware**: `#pan`, `#mixing bowl{}`
96
+ - **Timers**: `~{5%minutes}`, `~pasta{10%minutes}`
97
+ - **Comments**: `-- line comment`, `[- block comment -]`
98
+ - **Metadata**: `>> key: value` or YAML front matter
26
99
 
27
100
  ## Development
28
101
 
29
- After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
102
+ ```bash
103
+ # Install dependencies
104
+ bundle install
105
+
106
+ # Run tests
107
+ bundle exec rspec
108
+
109
+ # Run linter
110
+ bundle exec rubocop
111
+ ```
112
+
113
+ ## Resources
30
114
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
115
+ - [Cooklang Website](https://cooklang.org)
116
+ - [Language Specification](https://cooklang.org/docs/spec/)
32
117
 
33
118
  ## Contributing
34
119
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/cooklang.
120
+ Bug reports and pull requests welcome on GitHub.
121
+
122
+ ## License
123
+
124
+ MIT License
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ class Cookware
5
+ attr_reader :name, :quantity
6
+
7
+ def initialize(name:, quantity: nil)
8
+ @name = name.to_s.freeze
9
+ @quantity = quantity
10
+ end
11
+
12
+ def to_s
13
+ result = @name
14
+ result += " (#{@quantity})" if @quantity
15
+ result
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ name: @name,
21
+ quantity: @quantity
22
+ }.compact
23
+ end
24
+
25
+ def ==(other)
26
+ return false unless other.is_a?(Cookware)
27
+
28
+ name == other.name && quantity == other.quantity
29
+ end
30
+
31
+ def eql?(other)
32
+ self == other
33
+ end
34
+
35
+ def hash
36
+ [name, quantity].hash
37
+ end
38
+
39
+ def has_quantity?
40
+ !@quantity.nil?
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ class Formatter
5
+ attr_reader :recipe
6
+
7
+ def initialize(recipe)
8
+ @recipe = recipe
9
+ end
10
+
11
+ def to_s
12
+ raise NotImplementedError, "Subclasses must implement #to_s"
13
+ end
14
+
15
+ private
16
+ def ingredients_section
17
+ return "" if recipe.ingredients.empty?
18
+
19
+ ingredient_lines = recipe.ingredients.map do |ingredient|
20
+ format_ingredient(ingredient)
21
+ end
22
+
23
+ "Ingredients:\n#{ingredient_lines.join("\n")}\n"
24
+ end
25
+
26
+ def steps_section
27
+ return "" if recipe.steps.empty?
28
+
29
+ step_lines = recipe.steps.each_with_index.map do |step, index|
30
+ " #{index + 1}. #{step.to_text}"
31
+ end
32
+
33
+ "Steps:\n#{step_lines.join("\n")}\n"
34
+ end
35
+
36
+ def format_ingredient(ingredient)
37
+ name = ingredient.name
38
+ quantity_unit = format_quantity_unit(ingredient)
39
+
40
+ # Add 4 spaces after the longest ingredient name for alignment
41
+ name_width = max_ingredient_name_length + 4
42
+ " #{name.ljust(name_width)}#{quantity_unit}"
43
+ end
44
+
45
+ def format_quantity_unit(ingredient)
46
+ if ingredient.quantity && ingredient.unit
47
+ "#{ingredient.quantity} #{ingredient.unit}"
48
+ elsif ingredient.quantity
49
+ ingredient.quantity.to_s
50
+ elsif ingredient.unit
51
+ ingredient.unit
52
+ else
53
+ "some"
54
+ end
55
+ end
56
+
57
+ def max_ingredient_name_length
58
+ @max_ingredient_name_length ||= recipe.ingredients.map(&:name).map(&:length).max || 0
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatter"
4
+
5
+ module Cooklang
6
+ module Formatters
7
+ class Text < Formatter
8
+ def to_s
9
+ sections = []
10
+
11
+ sections << ingredients_section unless recipe.ingredients.empty?
12
+ sections << steps_section unless recipe.steps.empty?
13
+
14
+ sections.join("\n").strip
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ class Ingredient
5
+ attr_reader :name, :quantity, :unit, :notes
6
+
7
+ def initialize(name:, quantity: nil, unit: nil, notes: nil)
8
+ @name = name.to_s.freeze
9
+ @quantity = quantity
10
+ @unit = unit&.to_s&.freeze
11
+ @notes = notes&.to_s&.freeze
12
+ end
13
+
14
+ def to_s
15
+ result = @name
16
+ result += " #{@quantity}" if @quantity
17
+ result += " #{@unit}" if @unit
18
+ result += " (#{@notes})" if @notes
19
+ result
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ name: @name,
25
+ quantity: @quantity,
26
+ unit: @unit,
27
+ notes: @notes
28
+ }.compact
29
+ end
30
+
31
+ def ==(other)
32
+ return false unless other.is_a?(Ingredient)
33
+
34
+ name == other.name &&
35
+ quantity == other.quantity &&
36
+ unit == other.unit &&
37
+ notes == other.notes
38
+ end
39
+
40
+ def eql?(other)
41
+ self == other
42
+ end
43
+
44
+ def hash
45
+ [name, quantity, unit, notes].hash
46
+ end
47
+
48
+ def has_quantity?
49
+ !@quantity.nil?
50
+ end
51
+
52
+ def has_unit?
53
+ !@unit.nil?
54
+ end
55
+
56
+ def has_notes?
57
+ !@notes.nil?
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,291 @@
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
+ # Handle YAML front matter
46
+ elsif match_comment_block
47
+ # Handle block comments
48
+ elsif match_comment_line
49
+ # Handle line comments
50
+ elsif match_metadata_marker
51
+ # Handle >> metadata
52
+ elsif match_section_marker
53
+ # Handle = section markers
54
+ elsif match_note_marker
55
+ # Handle > note markers (only if not part of >>)
56
+ elsif match_special_chars
57
+ # Handle single character tokens
58
+ elsif match_newline
59
+ # Handle newlines
60
+ elsif match_text
61
+ # Handle plain text
62
+ elsif match_hyphen
63
+ # Handle single hyphens that aren't part of comments
64
+ else
65
+ # Skip unrecognized characters
66
+ advance_position(@scanner.getch)
67
+ end
68
+ end
69
+
70
+ @tokens
71
+ end
72
+
73
+ private
74
+ def current_position
75
+ @scanner.pos
76
+ end
77
+
78
+ def current_line
79
+ @line
80
+ end
81
+
82
+ def current_column
83
+ @column
84
+ end
85
+
86
+ def advance_position(text)
87
+ text.each_char do |char|
88
+ if char == "\n"
89
+ @line += 1
90
+ @column = 1
91
+ else
92
+ @column += 1
93
+ end
94
+ end
95
+ end
96
+
97
+ def add_token(type, value)
98
+ position = current_position
99
+ line = current_line
100
+ column = current_column
101
+ @tokens << Token.new(type, value, position, line, column)
102
+ advance_position(value)
103
+ end
104
+
105
+ def capture_single_char_token(type)
106
+ position = current_position
107
+ line = current_line
108
+ column = current_column
109
+ value = @scanner.getch
110
+ @tokens << Token.new(type, value, position, line, column)
111
+ advance_position(value)
112
+ true
113
+ end
114
+
115
+ def match_yaml_delimiter
116
+ if @scanner.check(/^---/)
117
+ position = current_position
118
+ line = current_line
119
+ column = current_column
120
+ value = @scanner.scan("---")
121
+ @tokens << Token.new(:yaml_delimiter, value, position, line, column)
122
+ advance_position(value)
123
+ true
124
+ else
125
+ false
126
+ end
127
+ end
128
+
129
+ def match_comment_block
130
+ if @scanner.check(/\[-/)
131
+ position = current_position
132
+ line = current_line
133
+ column = current_column
134
+ @scanner.scan("[-")
135
+ @tokens << Token.new(:comment_block_start, "[-", position, line, column)
136
+ advance_position("[-")
137
+
138
+ # Scan until block end or EOF
139
+ content = ""
140
+ content += @scanner.getch while !@scanner.eos? && !@scanner.check(/-\]/)
141
+
142
+ add_token(:text, content) unless content.empty?
143
+
144
+ if @scanner.check(/-\]/)
145
+ position = current_position
146
+ line = current_line
147
+ column = current_column
148
+ @scanner.scan("-]")
149
+ @tokens << Token.new(:comment_block_end, "-]", position, line, column)
150
+ advance_position("-]")
151
+ end
152
+
153
+ true
154
+ else
155
+ false
156
+ end
157
+ end
158
+
159
+ def match_comment_line
160
+ if @scanner.check(/--/)
161
+ position = current_position
162
+ line = current_line
163
+ column = current_column
164
+ value = @scanner.scan("--")
165
+ @tokens << Token.new(:comment_line, value, position, line, column)
166
+ advance_position(value)
167
+
168
+ # Scan rest of line
169
+ line_content = @scanner.scan(/[^\n]*/)
170
+ add_token(:text, line_content) if line_content && !line_content.empty?
171
+
172
+ true
173
+ else
174
+ false
175
+ end
176
+ end
177
+
178
+ def match_metadata_marker
179
+ if @scanner.check(/>>/)
180
+ position = current_position
181
+ line = current_line
182
+ column = current_column
183
+ value = @scanner.scan(">>")
184
+ @tokens << Token.new(:metadata_marker, value, position, line, column)
185
+ advance_position(value)
186
+ true
187
+ else
188
+ false
189
+ end
190
+ end
191
+
192
+ def match_section_marker
193
+ if @scanner.check(/=+/)
194
+ position = current_position
195
+ line = current_line
196
+ column = current_column
197
+ value = @scanner.scan(/=+/)
198
+ @tokens << Token.new(:section_marker, value, position, line, column)
199
+ advance_position(value)
200
+ true
201
+ else
202
+ false
203
+ end
204
+ end
205
+
206
+ def match_note_marker
207
+ # Only match > if it's not part of >>
208
+ if @scanner.check(/>/) && !@scanner.check(/>>/)
209
+ position = current_position
210
+ line = current_line
211
+ column = current_column
212
+ value = @scanner.scan(">")
213
+ @tokens << Token.new(:note_marker, value, position, line, column)
214
+ advance_position(value)
215
+ true
216
+ else
217
+ false
218
+ end
219
+ end
220
+
221
+ def match_special_chars
222
+ char = @scanner.check(/./)
223
+
224
+ case char
225
+ when "@"
226
+ capture_single_char_token(:ingredient_marker)
227
+ when "#"
228
+ capture_single_char_token(:cookware_marker)
229
+ when "~"
230
+ capture_single_char_token(:timer_marker)
231
+ when "{"
232
+ capture_single_char_token(:open_brace)
233
+ when "}"
234
+ capture_single_char_token(:close_brace)
235
+ when "("
236
+ capture_single_char_token(:open_paren)
237
+ when ")"
238
+ capture_single_char_token(:close_paren)
239
+ when "%"
240
+ capture_single_char_token(:percent)
241
+ else
242
+ false
243
+ end
244
+ end
245
+
246
+ def match_newline
247
+ if @scanner.check(/\n/)
248
+ position = current_position
249
+ line = current_line
250
+ column = current_column
251
+ value = @scanner.scan("\n")
252
+ @tokens << Token.new(:newline, value, position, line, column)
253
+ advance_position(value)
254
+ true
255
+ else
256
+ false
257
+ end
258
+ end
259
+
260
+ def match_text
261
+ # Match any text that's not a special character, including spaces and tabs
262
+ # Exclude [ and ] to allow block comment detection
263
+ # Exclude = and > to allow section and note markers
264
+ if @scanner.check(/[^@#~{}()%\n\-\[\]=>]+/)
265
+ position = current_position
266
+ line = current_line
267
+ column = current_column
268
+ text = @scanner.scan(/[^@#~{}()%\n\-\[\]=>]+/)
269
+ @tokens << Token.new(:text, text, position, line, column)
270
+ advance_position(text)
271
+ true
272
+ else
273
+ false
274
+ end
275
+ end
276
+
277
+ def match_hyphen
278
+ if @scanner.check(/-/) && !@scanner.check(/--/)
279
+ position = current_position
280
+ line = current_line
281
+ column = current_column
282
+ @scanner.scan("-")
283
+ @tokens << Token.new(:hyphen, "-", position, line, column)
284
+ advance_position("-")
285
+ true
286
+ else
287
+ false
288
+ end
289
+ end
290
+ end
291
+ end