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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +35 -0
- data/.gitignore +12 -0
- data/.qlty/.gitignore +7 -0
- data/.qlty/configs/.yamllint.yaml +21 -0
- data/.qlty/qlty.toml +101 -0
- data/.rspec +3 -0
- data/.rubocop.yml +340 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +84 -0
- data/README.md +10 -5
- data/Rakefile +12 -0
- data/cooklang.gemspec +35 -0
- data/lib/cooklang/builders/recipe_builder.rb +64 -0
- data/lib/cooklang/builders/step_builder.rb +76 -0
- data/lib/cooklang/lexer.rb +5 -14
- data/lib/cooklang/parser.rb +24 -653
- data/lib/cooklang/parsers/cookware_parser.rb +133 -0
- data/lib/cooklang/parsers/ingredient_parser.rb +179 -0
- data/lib/cooklang/parsers/timer_parser.rb +135 -0
- data/lib/cooklang/processors/element_parser.rb +45 -0
- data/lib/cooklang/processors/metadata_processor.rb +129 -0
- data/lib/cooklang/processors/step_processor.rb +208 -0
- data/lib/cooklang/processors/token_processor.rb +104 -0
- data/lib/cooklang/recipe.rb +25 -15
- data/lib/cooklang/step.rb +12 -2
- data/lib/cooklang/timer.rb +3 -1
- data/lib/cooklang/token_stream.rb +130 -0
- data/lib/cooklang/version.rb +1 -1
- data/spec/comprehensive_spec.rb +179 -0
- data/spec/cooklang_spec.rb +38 -0
- data/spec/fixtures/canonical.yaml +837 -0
- data/spec/formatters/text_spec.rb +189 -0
- data/spec/integration/canonical_spec.rb +211 -0
- data/spec/lexer_spec.rb +357 -0
- data/spec/models/cookware_spec.rb +116 -0
- data/spec/models/ingredient_spec.rb +192 -0
- data/spec/models/metadata_spec.rb +241 -0
- data/spec/models/note_spec.rb +65 -0
- data/spec/models/recipe_spec.rb +171 -0
- data/spec/models/section_spec.rb +65 -0
- data/spec/models/step_spec.rb +236 -0
- data/spec/models/timer_spec.rb +173 -0
- data/spec/parser_spec.rb +398 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/token_stream_spec.rb +278 -0
- metadata +162 -6
@@ -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
|