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,179 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe "Comprehensive Cooklang Implementation" do
|
6
|
+
let(:parser) { Cooklang::Parser.new }
|
7
|
+
|
8
|
+
describe "Core Features from SPEC.md" do
|
9
|
+
context "Ingredients (@)" do
|
10
|
+
it "parses single word ingredients" do
|
11
|
+
recipe = parser.parse("@salt")
|
12
|
+
expect(recipe.ingredients.first.name).to eq("salt")
|
13
|
+
expect(recipe.ingredients.first.quantity).to eq("some")
|
14
|
+
end
|
15
|
+
|
16
|
+
it "parses multi-word ingredients with {}" do
|
17
|
+
recipe = parser.parse("@ground black pepper{}")
|
18
|
+
expect(recipe.ingredients.first.name).to eq("ground black pepper")
|
19
|
+
end
|
20
|
+
|
21
|
+
it "parses ingredients with quantity" do
|
22
|
+
recipe = parser.parse("@potato{2}")
|
23
|
+
expect(recipe.ingredients.first.name).to eq("potato")
|
24
|
+
expect(recipe.ingredients.first.quantity).to eq(2)
|
25
|
+
end
|
26
|
+
|
27
|
+
it "parses ingredients with quantity and unit" do
|
28
|
+
recipe = parser.parse("@bacon strips{1%kg}")
|
29
|
+
expect(recipe.ingredients.first.name).to eq("bacon strips")
|
30
|
+
expect(recipe.ingredients.first.quantity).to eq(1)
|
31
|
+
expect(recipe.ingredients.first.unit).to eq("kg")
|
32
|
+
end
|
33
|
+
|
34
|
+
it "parses ingredients with notes/preparations" do
|
35
|
+
recipe = parser.parse("@onion{1}(peeled and finely chopped)")
|
36
|
+
expect(recipe.ingredients.first.name).to eq("onion")
|
37
|
+
expect(recipe.ingredients.first.quantity).to eq(1)
|
38
|
+
expect(recipe.ingredients.first.notes).to eq("peeled and finely chopped")
|
39
|
+
end
|
40
|
+
|
41
|
+
it "parses fractional quantities" do
|
42
|
+
recipe = parser.parse("@syrup{1/2%tbsp}")
|
43
|
+
expect(recipe.ingredients.first.quantity).to eq("1/2")
|
44
|
+
expect(recipe.ingredients.first.unit).to eq("tbsp")
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
context "Cookware (#)" do
|
49
|
+
it "parses single word cookware" do
|
50
|
+
recipe = parser.parse("Place into a #pot.")
|
51
|
+
expect(recipe.cookware.first.name).to eq("pot")
|
52
|
+
end
|
53
|
+
|
54
|
+
it "parses multi-word cookware with {}" do
|
55
|
+
recipe = parser.parse("Mash with a #potato masher{}.")
|
56
|
+
expect(recipe.cookware.first.name).to eq("potato masher")
|
57
|
+
end
|
58
|
+
|
59
|
+
it "parses cookware with quantity" do
|
60
|
+
recipe = parser.parse("Use #baking sheet{2}")
|
61
|
+
expect(recipe.cookware.first.name).to eq("baking sheet")
|
62
|
+
expect(recipe.cookware.first.quantity).to eq(2)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
context "Timers (~)" do
|
67
|
+
it "parses anonymous timers" do
|
68
|
+
recipe = parser.parse("Bake for ~{25%minutes}.")
|
69
|
+
expect(recipe.timers.first.duration).to eq(25)
|
70
|
+
expect(recipe.timers.first.unit).to eq("minutes")
|
71
|
+
expect(recipe.timers.first.name).to be_nil
|
72
|
+
end
|
73
|
+
|
74
|
+
it "parses named timers" do
|
75
|
+
recipe = parser.parse("Boil @eggs{2} for ~eggs{3%minutes}.")
|
76
|
+
expect(recipe.timers.first.name).to eq("eggs")
|
77
|
+
expect(recipe.timers.first.duration).to eq(3)
|
78
|
+
expect(recipe.timers.first.unit).to eq("minutes")
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
context "Steps" do
|
83
|
+
it "separates steps by blank lines" do
|
84
|
+
recipe = parser.parse("Step 1\n\nStep 2\n\nStep 3")
|
85
|
+
expect(recipe.steps.size).to eq(3)
|
86
|
+
end
|
87
|
+
|
88
|
+
it "keeps multi-line steps together" do
|
89
|
+
recipe = parser.parse("A step,\nthe same step.\n\nA different step.")
|
90
|
+
expect(recipe.steps.size).to eq(2)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
context "Comments" do
|
95
|
+
it "strips line comments with --" do
|
96
|
+
recipe = parser.parse("-- Don't burn!\n@salt")
|
97
|
+
expect(recipe.steps_text.join).not_to include("Don't burn")
|
98
|
+
expect(recipe.ingredients.first.name).to eq("salt")
|
99
|
+
end
|
100
|
+
|
101
|
+
it "strips inline comments" do
|
102
|
+
recipe = parser.parse("@potato{2} -- comment here")
|
103
|
+
expect(recipe.steps_text.join).not_to include("comment here")
|
104
|
+
end
|
105
|
+
|
106
|
+
it "strips block comments with [- -]" do
|
107
|
+
recipe = parser.parse("Add @milk{4%cup} [- my comment -], mix")
|
108
|
+
expect(recipe.steps_text.join).not_to include("my comment")
|
109
|
+
expect(recipe.steps_text.join).to include("mix")
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
context "Metadata" do
|
114
|
+
it "parses YAML front matter" do
|
115
|
+
input = "---\ntitle: Test Recipe\nservings: 4\n---\n@salt"
|
116
|
+
recipe = parser.parse(input)
|
117
|
+
expect(recipe.metadata["title"]).to eq("Test Recipe")
|
118
|
+
expect(recipe.metadata["servings"]).to eq(4)
|
119
|
+
end
|
120
|
+
|
121
|
+
it "parses inline metadata with >>" do
|
122
|
+
recipe = parser.parse(">> servings: 4\n@salt")
|
123
|
+
expect(recipe.metadata["servings"]).to eq(4)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
context "Notes (>)" do
|
128
|
+
it "parses notes with > prefix" do
|
129
|
+
recipe = parser.parse("> Don't burn the roux!\n@salt")
|
130
|
+
expect(recipe.notes.first.content).to eq("Don't burn the roux!")
|
131
|
+
end
|
132
|
+
|
133
|
+
it "distinguishes notes from metadata (>>)" do
|
134
|
+
recipe = parser.parse(">> servings: 4\n> This is a note")
|
135
|
+
expect(recipe.metadata["servings"]).to eq(4)
|
136
|
+
expect(recipe.notes.first.content).to eq("This is a note")
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
context "Sections (=)" do
|
141
|
+
it "parses sections with single =" do
|
142
|
+
recipe = parser.parse("= Dough\n@flour{200%g}")
|
143
|
+
expect(recipe.sections.first.name).to eq("Dough")
|
144
|
+
end
|
145
|
+
|
146
|
+
it "parses sections with double ==" do
|
147
|
+
recipe = parser.parse("== Filling ==\n@cheese{100%g}")
|
148
|
+
expect(recipe.sections.first.name).to eq("Filling")
|
149
|
+
end
|
150
|
+
|
151
|
+
it "handles sections without names" do
|
152
|
+
recipe = parser.parse("=\n@salt")
|
153
|
+
expect(recipe.sections).to be_empty # unnamed sections not stored
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
describe "Advanced Features" do
|
159
|
+
context "Short-hand preparations (in notes)" do
|
160
|
+
it "handles preparation notes in parentheses" do
|
161
|
+
recipe = parser.parse("@garlic{2%cloves}(peeled and minced)")
|
162
|
+
expect(recipe.ingredients.first.notes).to eq("peeled and minced")
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
describe "Edge Cases and Unicode" do
|
168
|
+
it "handles Unicode characters in ingredients" do
|
169
|
+
recipe = parser.parse("Add @chilli⸫ then bake")
|
170
|
+
expect(recipe.ingredients.first.name).to eq("chilli")
|
171
|
+
end
|
172
|
+
|
173
|
+
it "handles invalid syntax gracefully" do
|
174
|
+
recipe = parser.parse("Message @ example")
|
175
|
+
# Invalid ingredients should not be parsed
|
176
|
+
expect(recipe.ingredients).to be_empty
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe Cooklang do
|
6
|
+
it "has a version number" do
|
7
|
+
expect(Cooklang::VERSION).not_to be nil
|
8
|
+
end
|
9
|
+
|
10
|
+
describe ".parse" do
|
11
|
+
it "returns a Recipe object" do
|
12
|
+
recipe = Cooklang.parse("")
|
13
|
+
|
14
|
+
expect(recipe).to be_a(Cooklang::Recipe)
|
15
|
+
end
|
16
|
+
|
17
|
+
it "returns recipe with empty collections for empty input" do
|
18
|
+
recipe = Cooklang.parse("")
|
19
|
+
|
20
|
+
expect(recipe.ingredients).to be_empty
|
21
|
+
expect(recipe.cookware).to be_empty
|
22
|
+
expect(recipe.timers).to be_empty
|
23
|
+
expect(recipe.steps).to be_empty
|
24
|
+
expect(recipe.metadata).to be_a(Cooklang::Metadata)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
describe ".parse_file" do
|
29
|
+
it "reads and parses a file" do
|
30
|
+
allow(File).to receive(:read).with("recipe.cook").and_return("test content")
|
31
|
+
allow(Cooklang).to receive(:parse).with("test content").and_return("parsed result")
|
32
|
+
|
33
|
+
result = Cooklang.parse_file("recipe.cook")
|
34
|
+
|
35
|
+
expect(result).to eq("parsed result")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|