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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Formatters::Text do
6
+ let(:recipe) do
7
+ Cooklang::Recipe.new(
8
+ ingredients: ingredients,
9
+ steps: steps,
10
+ cookware: [],
11
+ timers: [],
12
+ metadata: {},
13
+ sections: [],
14
+ notes: []
15
+ )
16
+ end
17
+ let(:formatter) { described_class.new(recipe) }
18
+
19
+ describe "#to_s" do
20
+ context "with ingredients only" do
21
+ let(:ingredients) do
22
+ [
23
+ Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g"),
24
+ Cooklang::Ingredient.new(name: "milk", quantity: 250, unit: "ml"),
25
+ Cooklang::Ingredient.new(name: "eggs", quantity: 3),
26
+ Cooklang::Ingredient.new(name: "butter"),
27
+ Cooklang::Ingredient.new(name: "sea salt", quantity: 1, unit: "pinch")
28
+ ]
29
+ end
30
+ let(:steps) { [] }
31
+
32
+ it "formats ingredients with aligned columns" do
33
+ expected = <<~OUTPUT.strip
34
+ Ingredients:
35
+ flour 125 g
36
+ milk 250 ml
37
+ eggs 3
38
+ butter some
39
+ sea salt 1 pinch
40
+ OUTPUT
41
+
42
+ expect(formatter.to_s).to eq(expected)
43
+ end
44
+ end
45
+
46
+ context "with steps only" do
47
+ let(:ingredients) { [] }
48
+ let(:steps) do
49
+ [
50
+ Cooklang::Step.new(segments: [
51
+ { type: "text", value: "Crack the " },
52
+ { type: "ingredient", value: "eggs", name: "eggs" },
53
+ { type: "text", value: " into a blender" }
54
+ ]),
55
+ Cooklang::Step.new(segments: [
56
+ { type: "text", value: "Pour into a bowl and leave to stand for " },
57
+ { type: "timer", value: "15 minutes", name: nil },
58
+ { type: "text", value: "." }
59
+ ])
60
+ ]
61
+ end
62
+
63
+ it "formats steps with numbered list" do
64
+ expected = <<~OUTPUT.strip
65
+ Steps:
66
+ 1. Crack the eggs into a blender
67
+ 2. Pour into a bowl and leave to stand for 15 minutes.
68
+ OUTPUT
69
+
70
+ expect(formatter.to_s).to eq(expected)
71
+ end
72
+ end
73
+
74
+ context "with both ingredients and steps" do
75
+ let(:ingredients) do
76
+ [
77
+ Cooklang::Ingredient.new(name: "butter"),
78
+ Cooklang::Ingredient.new(name: "eggs", quantity: 3),
79
+ Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g"),
80
+ Cooklang::Ingredient.new(name: "milk", quantity: 250, unit: "ml"),
81
+ Cooklang::Ingredient.new(name: "sea salt", quantity: 1, unit: "pinch")
82
+ ]
83
+ end
84
+ let(:steps) do
85
+ [
86
+ Cooklang::Step.new(segments: [
87
+ { type: "text", value: "Crack the " },
88
+ { type: "ingredient", value: "eggs", name: "eggs" },
89
+ { type: "text", value: " into a blender, then add the " },
90
+ { type: "ingredient", value: "flour", name: "flour" },
91
+ { type: "text", value: ", " },
92
+ { type: "ingredient", value: "milk", name: "milk" },
93
+ { type: "text", value: " and " },
94
+ { type: "ingredient", value: "sea salt", name: "sea salt" },
95
+ { type: "text", value: "." }
96
+ ]),
97
+ Cooklang::Step.new(segments: [
98
+ { type: "text", value: "Pour into a bowl and leave to stand for " },
99
+ { type: "timer", value: "15 minutes", name: nil },
100
+ { type: "text", value: "." }
101
+ ]),
102
+ Cooklang::Step.new(segments: [
103
+ { type: "text", value: "Melt the " },
104
+ { type: "ingredient", value: "butter", name: "butter" },
105
+ { type: "text", value: " in a large non-stick " },
106
+ { type: "cookware", value: "frying pan", name: "frying pan" },
107
+ { type: "text", value: "." }
108
+ ])
109
+ ]
110
+ end
111
+
112
+ it "formats complete recipe" do
113
+ expected = <<~OUTPUT.strip
114
+ Ingredients:
115
+ butter some
116
+ eggs 3
117
+ flour 125 g
118
+ milk 250 ml
119
+ sea salt 1 pinch
120
+
121
+ Steps:
122
+ 1. Crack the eggs into a blender, then add the flour, milk and sea salt.
123
+ 2. Pour into a bowl and leave to stand for 15 minutes.
124
+ 3. Melt the butter in a large non-stick frying pan.
125
+ OUTPUT
126
+
127
+ expect(formatter.to_s).to eq(expected)
128
+ end
129
+ end
130
+
131
+ context "with empty recipe" do
132
+ let(:ingredients) { [] }
133
+ let(:steps) { [] }
134
+
135
+ it "returns empty string" do
136
+ expect(formatter.to_s).to eq("")
137
+ end
138
+ end
139
+
140
+ context "with various ingredient formats" do
141
+ let(:ingredients) do
142
+ [
143
+ Cooklang::Ingredient.new(name: "onion", quantity: 1),
144
+ Cooklang::Ingredient.new(name: "olive oil", unit: "drizzle"),
145
+ Cooklang::Ingredient.new(name: "salt"),
146
+ Cooklang::Ingredient.new(name: "pepper", quantity: "some", unit: "grinds")
147
+ ]
148
+ end
149
+ let(:steps) { [] }
150
+
151
+ it "handles missing quantities and units gracefully" do
152
+ expected = <<~OUTPUT.strip
153
+ Ingredients:
154
+ onion 1
155
+ olive oil drizzle
156
+ salt some
157
+ pepper some grinds
158
+ OUTPUT
159
+
160
+ expect(formatter.to_s).to eq(expected)
161
+ end
162
+ end
163
+ end
164
+
165
+ describe "#format_quantity_unit" do
166
+ let(:formatter) { described_class.new(recipe) }
167
+ let(:recipe) { double("recipe") }
168
+
169
+ it "formats quantity with unit" do
170
+ ingredient = Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g")
171
+ expect(formatter.send(:format_quantity_unit, ingredient)).to eq("125 g")
172
+ end
173
+
174
+ it "formats quantity without unit" do
175
+ ingredient = Cooklang::Ingredient.new(name: "eggs", quantity: 3)
176
+ expect(formatter.send(:format_quantity_unit, ingredient)).to eq("3")
177
+ end
178
+
179
+ it "formats unit without quantity" do
180
+ ingredient = Cooklang::Ingredient.new(name: "olive oil", unit: "drizzle")
181
+ expect(formatter.send(:format_quantity_unit, ingredient)).to eq("drizzle")
182
+ end
183
+
184
+ it "handles missing quantity and unit" do
185
+ ingredient = Cooklang::Ingredient.new(name: "salt")
186
+ expect(formatter.send(:format_quantity_unit, ingredient)).to eq("some")
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "yaml"
5
+
6
+ RSpec.describe "Canonical Tests" do
7
+ # Load the canonical test suite from the official Cooklang spec
8
+ CANONICAL_TESTS = YAML.load_file(File.expand_path("../fixtures/canonical.yaml", __dir__))
9
+
10
+ describe "test file structure" do
11
+ it "has a version" do
12
+ expect(CANONICAL_TESTS).to have_key("version")
13
+ expect(CANONICAL_TESTS["version"]).to be_a(Integer)
14
+ end
15
+
16
+ it "has tests" do
17
+ expect(CANONICAL_TESTS).to have_key("tests")
18
+ expect(CANONICAL_TESTS["tests"]).to be_a(Hash)
19
+ end
20
+
21
+ it "has valid test structure" do
22
+ CANONICAL_TESTS["tests"].each do |test_name, test_data|
23
+ expect(test_data).to have_key("source"), "Test #{test_name} missing 'source'"
24
+ expect(test_data).to have_key("result"), "Test #{test_name} missing 'result'"
25
+ expect(test_data["source"]).to be_a(String), "Test #{test_name} source should be a string"
26
+ expect(test_data["result"]).to be_a(Hash), "Test #{test_name} result should be a hash"
27
+ end
28
+ end
29
+ end
30
+
31
+ describe "lexer compatibility" do
32
+ # For now, we just verify that our lexer can tokenize all the test sources
33
+ # without errors. We're not checking the results yet.
34
+ CANONICAL_TESTS["tests"].each do |test_name, test_data|
35
+ it "can tokenize: #{test_name}" do
36
+ source = test_data["source"]
37
+
38
+ # Our lexer should be able to tokenize any valid Cooklang source
39
+ lexer = Cooklang::Lexer.new(source)
40
+ tokens = lexer.tokenize
41
+
42
+ # Basic sanity checks
43
+ expect(tokens).to be_an(Array)
44
+
45
+ # Empty source should produce empty tokens
46
+ if source.strip.empty?
47
+ expect(tokens).to be_empty
48
+ else
49
+ # Non-empty source should produce some tokens
50
+ expect(tokens).not_to be_empty unless source.strip == "--" || source.strip.start_with?("--")
51
+ end
52
+
53
+ # All tokens should be valid Token objects
54
+ tokens.each do |token|
55
+ expect(token).to be_a(Cooklang::Token)
56
+ expect(token.type).to be_a(Symbol)
57
+ expect(token.value).to be_a(String)
58
+ expect(token.position).to be_a(Integer)
59
+ expect(token.line).to be_a(Integer)
60
+ expect(token.column).to be_a(Integer)
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ describe "test coverage analysis" do
67
+ it "covers all major Cooklang features" do
68
+ test_names = CANONICAL_TESTS["tests"].keys
69
+
70
+ # Check that we have tests for major features
71
+ expect(test_names.any? { |n| n.downcase.include?("ingredient") }).to be true
72
+ expect(test_names.any? { |n| n.downcase.include?("cookware") }).to be true
73
+ expect(test_names.any? { |n| n.downcase.include?("timer") }).to be true
74
+ expect(test_names.any? { |n| n.downcase.include?("metadata") }).to be true
75
+ expect(test_names.any? { |n| n.downcase.include?("comment") }).to be true
76
+ end
77
+
78
+ it "has at least 20 test cases" do
79
+ expect(CANONICAL_TESTS["tests"].size).to be >= 20
80
+ end
81
+ end
82
+
83
+ # Parser result tests - validates that our parser produces the exact same
84
+ # structure as the canonical test expectations
85
+ describe "parser results" do
86
+ CANONICAL_TESTS["tests"].each do |test_name, test_data|
87
+ it "parses correctly: #{test_name}" do
88
+ source = test_data["source"]
89
+ expected = test_data["result"]
90
+
91
+ recipe = Cooklang.parse(source)
92
+
93
+ # Convert our internal format to canonical format for comparison
94
+ actual = recipe_to_canonical_format(recipe)
95
+
96
+ # Compare the complete result structure
97
+ expect(actual).to eq(expected),
98
+ "Test #{test_name} failed.\nExpected: #{expected.inspect}\nActual: #{actual.inspect}"
99
+ end
100
+ end
101
+ end
102
+
103
+ private
104
+ # Convert our Recipe object to the canonical test format
105
+ def recipe_to_canonical_format(recipe)
106
+ {
107
+ "steps" => recipe.steps.map { |step| step_to_canonical_format(step) },
108
+ "metadata" => recipe.metadata.to_h
109
+ }
110
+ end
111
+
112
+ # Combine adjacent text segments (strings) into single segments
113
+ def combine_adjacent_text_segments(segments)
114
+ result = []
115
+ current_text = nil
116
+
117
+ segments.each do |segment|
118
+ if segment.is_a?(String)
119
+ # Convert standalone newlines to spaces per Cooklang spec
120
+ segment_text = segment == "\n" ? " " : segment
121
+
122
+ if current_text
123
+ current_text += segment_text
124
+ else
125
+ current_text = segment_text
126
+ end
127
+ else
128
+ # Non-text segment - flush any accumulated text first
129
+ if current_text && !current_text.strip.empty?
130
+ result << current_text
131
+ current_text = nil
132
+ elsif current_text
133
+ # Discard whitespace-only text segments
134
+ current_text = nil
135
+ end
136
+ result << segment
137
+ end
138
+ end
139
+
140
+ # Don't forget the last text segment if there is one (and it's not just whitespace)
141
+ result << current_text if current_text && !current_text.strip.empty?
142
+
143
+ result
144
+ end
145
+
146
+ # Convert quantity values to match canonical expectations
147
+ def convert_quantity_to_canonical_format(quantity)
148
+ return quantity unless quantity.is_a?(String)
149
+
150
+ # Handle fractions like "1/2" or "1 / 2" but NOT "01/2" (leading zeros invalid)
151
+ # Following Rust implementation: only Int tokens (no leading zeros) are valid for fractions
152
+ # Pattern: valid numbers are either "0" or start with 1-9
153
+ if quantity.match(%r{^\s*(0|[1-9]\d*)\s*/\s*(0|[1-9]\d*)\s*$})
154
+ numerator = Regexp.last_match(1).to_f
155
+ denominator = Regexp.last_match(2).to_f
156
+ return numerator / denominator if denominator != 0
157
+ end
158
+
159
+ # Return as-is for non-fraction strings or invalid fractions
160
+ quantity
161
+ end
162
+
163
+ # Convert a Step object to canonical format (array of segment hashes)
164
+ def step_to_canonical_format(step)
165
+ # First, combine adjacent text segments to match canonical expectations
166
+ combined_segments = combine_adjacent_text_segments(step.segments)
167
+
168
+ combined_segments.map.with_index do |segment, index|
169
+ next_segment = combined_segments[index + 1]
170
+
171
+ case segment
172
+ when String
173
+ { "type" => "text", "value" => segment }
174
+ when Cooklang::Ingredient
175
+ result = {
176
+ "type" => "ingredient",
177
+ "name" => segment.name
178
+ }
179
+ if segment.quantity
180
+ # Convert fractions to decimals to match canonical test expectations
181
+ result["quantity"] = convert_quantity_to_canonical_format(segment.quantity)
182
+ end
183
+ # Always include units field, use empty string if nil
184
+ result["units"] = segment.unit || ""
185
+ result["notes"] = segment.notes if segment.notes
186
+ result
187
+ when Cooklang::Cookware
188
+ result = {
189
+ "type" => "cookware",
190
+ "name" => segment.name,
191
+ "quantity" => segment.quantity
192
+ }
193
+
194
+ # Add units field if the next segment starts with punctuation
195
+ result["units"] = "" if next_segment.is_a?(String) && next_segment.match(/^[^\w\s]/)
196
+
197
+ result
198
+ when Cooklang::Timer
199
+ result = {
200
+ "type" => "timer",
201
+ "quantity" => segment.duration ? convert_quantity_to_canonical_format(segment.duration) : "",
202
+ "units" => segment.unit || "",
203
+ "name" => segment.name || ""
204
+ }
205
+ result
206
+ else
207
+ raise "Unknown segment type: #{segment.class}"
208
+ end
209
+ end
210
+ end
211
+ end