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
data/cooklang.gemspec ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("../lib", __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "cooklang/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "cooklang"
9
+ spec.version = Cooklang::VERSION
10
+ spec.authors = ["James Brooks"]
11
+ spec.email = ["james@jamesbrooks.net"]
12
+
13
+ spec.summary = "A Ruby parser for the Cooklang recipe markup language."
14
+ spec.description = "Cooklang is a markup language for recipes that allows you to define ingredients, cookware, timers, and metadata in a structured way. This gem provides a Ruby parser for Cooklang files."
15
+ spec.homepage = "https://github.com/jamesbrooks/cooklang"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.2.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/jamesbrooks/cooklang"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ spec.files = `git ls-files`.split($/)
24
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
25
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_development_dependency "bundler", "~> 2.7"
29
+ spec.add_development_dependency "rake", "~> 13.0"
30
+ spec.add_development_dependency "rspec", "~> 3.13"
31
+ spec.add_development_dependency "rubocop", "~> 1.80"
32
+ spec.add_development_dependency "rubocop-performance", "~> 1.25"
33
+ spec.add_development_dependency "rubocop-rspec", "~> 3.6"
34
+ spec.add_development_dependency "simplecov", "~> 0.22.0"
35
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../recipe"
4
+ require_relative "../step"
5
+ require_relative "../section"
6
+
7
+ module Cooklang
8
+ module Builders
9
+ class RecipeBuilder
10
+ class << self
11
+ def build_recipe(parsed_steps, metadata)
12
+ # Aggregate all elements from steps
13
+ all_ingredients = []
14
+ all_cookware = []
15
+ all_timers = []
16
+ steps = []
17
+ sections = []
18
+
19
+ parsed_steps.each do |step_data|
20
+ all_ingredients.concat(step_data[:ingredients])
21
+ all_cookware.concat(step_data[:cookware])
22
+ all_timers.concat(step_data[:timers])
23
+
24
+ step = Step.new(segments: step_data[:segments])
25
+ steps << step
26
+
27
+ # Create section if this step has a section name
28
+ if step_data[:section_name]
29
+ sections << Section.new(name: step_data[:section_name], steps: [step])
30
+ end
31
+ end
32
+
33
+ # Deduplicate ingredients and cookware
34
+ unique_ingredients = deduplicate_ingredients(all_ingredients)
35
+ unique_cookware = deduplicate_cookware(all_cookware)
36
+
37
+ Recipe.new(
38
+ ingredients: unique_ingredients,
39
+ cookware: unique_cookware,
40
+ timers: all_timers,
41
+ steps: steps,
42
+ metadata: metadata,
43
+ sections: sections
44
+ )
45
+ end
46
+
47
+ private
48
+ def deduplicate_ingredients(ingredients)
49
+ # Group by name AND quantity to preserve different quantities of same ingredient
50
+ grouped = ingredients.group_by { |i| [i.name, i.quantity, i.unit] }
51
+ grouped.values.map(&:first)
52
+ end
53
+
54
+ def deduplicate_cookware(cookware_items)
55
+ # Group by name and prefer items with quantity over those without
56
+ cookware_items.group_by(&:name).map do |_name, items|
57
+ # Prefer items with quantity, then take the first one
58
+ items.find(&:quantity) || items.first
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../step"
4
+
5
+ module Cooklang
6
+ module Builders
7
+ class StepBuilder
8
+ def initialize
9
+ @segments = []
10
+ @ingredients = []
11
+ @cookware = []
12
+ @timers = []
13
+ @section_name = nil
14
+ end
15
+
16
+ def add_text(text)
17
+ @segments << text
18
+ self
19
+ end
20
+
21
+ def add_ingredient(ingredient)
22
+ @ingredients << ingredient
23
+ @segments << ingredient
24
+ self
25
+ end
26
+
27
+ def add_cookware(cookware)
28
+ @cookware << cookware
29
+ @segments << cookware
30
+ self
31
+ end
32
+
33
+ def add_timer(timer)
34
+ @timers << timer
35
+ @segments << timer
36
+ self
37
+ end
38
+
39
+ def add_remaining_text(text)
40
+ if text && !text.empty?
41
+ @segments << text
42
+ end
43
+ self
44
+ end
45
+
46
+ def set_section_name(name)
47
+ @section_name = name unless name&.empty?
48
+ self
49
+ end
50
+
51
+ def build
52
+ # Clean up segments - remove trailing newlines
53
+ cleaned_segments = remove_trailing_newlines(@segments.dup)
54
+
55
+ Step.new(segments: cleaned_segments)
56
+ end
57
+
58
+ # Check if the step has meaningful content
59
+ def has_content?
60
+ @segments.any? { |segment| segment != "\n" && !(segment.is_a?(String) && segment.strip.empty?) }
61
+ end
62
+
63
+ # Access internal collections for compatibility
64
+ attr_reader :segments, :ingredients, :cookware, :timers, :section_name
65
+
66
+ private
67
+ def remove_trailing_newlines(segments)
68
+ # Remove trailing newlines and whitespace-only text segments
69
+ segments.pop while !segments.empty? &&
70
+ (segments.last == "\n" ||
71
+ (segments.last.is_a?(String) && segments.last.strip.empty?))
72
+ segments
73
+ end
74
+ end
75
+ end
76
+ end
@@ -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