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,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Metadata do
6
+ describe "#initialize" do
7
+ it "creates empty metadata" do
8
+ metadata = described_class.new
9
+
10
+ expect(metadata).to be_empty
11
+ end
12
+
13
+ it "creates metadata from hash" do
14
+ data = { "title" => "Test Recipe", "servings" => 4 }
15
+ metadata = described_class.new(data)
16
+
17
+ expect(metadata["title"]).to eq("Test Recipe")
18
+ expect(metadata["servings"]).to eq(4)
19
+ end
20
+
21
+ it "converts symbol keys to strings" do
22
+ data = { title: "Test Recipe", servings: 4 }
23
+ metadata = described_class.new(data)
24
+
25
+ expect(metadata["title"]).to eq("Test Recipe")
26
+ expect(metadata["servings"]).to eq(4)
27
+ end
28
+ end
29
+
30
+ describe "hash access methods" do
31
+ let(:metadata) { described_class.new(title: "Test Recipe", servings: 4) }
32
+
33
+ describe "#[]=" do
34
+ it "converts keys to strings" do
35
+ metadata[:prep_time] = "10 minutes"
36
+
37
+ expect(metadata["prep_time"]).to eq("10 minutes")
38
+ end
39
+ end
40
+
41
+ describe "#[]" do
42
+ it "converts keys to strings" do
43
+ expect(metadata[:title]).to eq("Test Recipe")
44
+ expect(metadata["title"]).to eq("Test Recipe")
45
+ end
46
+ end
47
+
48
+ describe "#key?" do
49
+ it "converts keys to strings" do
50
+ expect(metadata.key?(:title)).to be true
51
+ expect(metadata.key?("title")).to be true
52
+ expect(metadata.key?(:nonexistent)).to be false
53
+ end
54
+ end
55
+
56
+ describe "#delete" do
57
+ it "converts keys to strings" do
58
+ metadata.delete(:title)
59
+
60
+ expect(metadata).not_to have_key("title")
61
+ end
62
+ end
63
+
64
+ describe "#fetch" do
65
+ it "converts keys to strings" do
66
+ expect(metadata.fetch(:title)).to eq("Test Recipe")
67
+ expect(metadata.fetch(:nonexistent, "default")).to eq("default")
68
+ end
69
+ end
70
+ end
71
+
72
+ describe "#to_h" do
73
+ it "returns plain hash" do
74
+ metadata = described_class.new(title: "Test Recipe", servings: 4)
75
+
76
+ hash = metadata.to_h
77
+
78
+ expect(hash).to be_a(Hash)
79
+ expect(hash).not_to be_a(described_class)
80
+ expect(hash).to eq({ "title" => "Test Recipe", "servings" => 4 })
81
+ end
82
+ end
83
+
84
+ describe "recipe-specific accessors" do
85
+ let(:metadata) { described_class.new }
86
+
87
+ describe "#servings" do
88
+ it "returns integer servings" do
89
+ metadata["servings"] = "4"
90
+
91
+ expect(metadata.servings).to eq(4)
92
+ end
93
+
94
+ it "returns nil when not set" do
95
+ expect(metadata.servings).to be_nil
96
+ end
97
+ end
98
+
99
+ describe "#servings=" do
100
+ it "sets servings" do
101
+ metadata.servings = 6
102
+
103
+ expect(metadata["servings"]).to eq(6)
104
+ end
105
+ end
106
+
107
+ describe "#prep_time" do
108
+ it "returns prep_time" do
109
+ metadata["prep_time"] = "15 minutes"
110
+
111
+ expect(metadata.prep_time).to eq("15 minutes")
112
+ end
113
+
114
+ it "returns prep-time as fallback" do
115
+ metadata["prep-time"] = "15 minutes"
116
+
117
+ expect(metadata.prep_time).to eq("15 minutes")
118
+ end
119
+
120
+ it "returns nil when not set" do
121
+ expect(metadata.prep_time).to be_nil
122
+ end
123
+ end
124
+
125
+ describe "#prep_time=" do
126
+ it "sets prep_time" do
127
+ metadata.prep_time = "20 minutes"
128
+
129
+ expect(metadata["prep_time"]).to eq("20 minutes")
130
+ end
131
+ end
132
+
133
+ describe "#cook_time" do
134
+ it "returns cook_time" do
135
+ metadata["cook_time"] = "30 minutes"
136
+
137
+ expect(metadata.cook_time).to eq("30 minutes")
138
+ end
139
+
140
+ it "returns cook-time as fallback" do
141
+ metadata["cook-time"] = "30 minutes"
142
+
143
+ expect(metadata.cook_time).to eq("30 minutes")
144
+ end
145
+ end
146
+
147
+ describe "#cook_time=" do
148
+ it "sets cook_time" do
149
+ metadata.cook_time = "25 minutes"
150
+
151
+ expect(metadata["cook_time"]).to eq("25 minutes")
152
+ end
153
+ end
154
+
155
+ describe "#total_time" do
156
+ it "returns total_time" do
157
+ metadata["total_time"] = "45 minutes"
158
+
159
+ expect(metadata.total_time).to eq("45 minutes")
160
+ end
161
+
162
+ it "returns total-time as fallback" do
163
+ metadata["total-time"] = "45 minutes"
164
+
165
+ expect(metadata.total_time).to eq("45 minutes")
166
+ end
167
+ end
168
+
169
+ describe "#total_time=" do
170
+ it "sets total_time" do
171
+ metadata.total_time = "50 minutes"
172
+
173
+ expect(metadata["total_time"]).to eq("50 minutes")
174
+ end
175
+ end
176
+
177
+ describe "#title" do
178
+ it "returns title" do
179
+ metadata["title"] = "Chocolate Cake"
180
+
181
+ expect(metadata.title).to eq("Chocolate Cake")
182
+ end
183
+ end
184
+
185
+ describe "#title=" do
186
+ it "sets title" do
187
+ metadata.title = "Vanilla Cake"
188
+
189
+ expect(metadata["title"]).to eq("Vanilla Cake")
190
+ end
191
+ end
192
+
193
+ describe "#source" do
194
+ it "returns source" do
195
+ metadata["source"] = "cookbook.com"
196
+
197
+ expect(metadata.source).to eq("cookbook.com")
198
+ end
199
+ end
200
+
201
+ describe "#source=" do
202
+ it "sets source" do
203
+ metadata.source = "my-blog.com"
204
+
205
+ expect(metadata["source"]).to eq("my-blog.com")
206
+ end
207
+ end
208
+
209
+ describe "#tags" do
210
+ it "returns array when tags is array" do
211
+ metadata["tags"] = ["dessert", "chocolate"]
212
+
213
+ expect(metadata.tags).to eq(["dessert", "chocolate"])
214
+ end
215
+
216
+ it "splits string tags on comma" do
217
+ metadata["tags"] = "dessert, chocolate, sweet"
218
+
219
+ expect(metadata.tags).to eq(["dessert", "chocolate", "sweet"])
220
+ end
221
+
222
+ it "returns empty array when not set" do
223
+ expect(metadata.tags).to eq([])
224
+ end
225
+
226
+ it "returns empty array for non-string, non-array values" do
227
+ metadata["tags"] = 123
228
+
229
+ expect(metadata.tags).to eq([])
230
+ end
231
+ end
232
+
233
+ describe "#tags=" do
234
+ it "sets tags" do
235
+ metadata.tags = ["breakfast", "quick"]
236
+
237
+ expect(metadata["tags"]).to eq(["breakfast", "quick"])
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Note do
6
+ describe "#initialize" do
7
+ it "creates note with content" do
8
+ note = described_class.new(content: "This is a note")
9
+ expect(note.content).to eq("This is a note")
10
+ end
11
+
12
+ it "converts content to string and freezes it" do
13
+ note = described_class.new(content: 123)
14
+ expect(note.content).to eq("123")
15
+ expect(note.content).to be_frozen
16
+ end
17
+ end
18
+
19
+ describe "#to_s" do
20
+ it "returns content" do
21
+ note = described_class.new(content: "Test note")
22
+ expect(note.to_s).to eq("Test note")
23
+ end
24
+ end
25
+
26
+ describe "#to_h" do
27
+ it "returns hash representation" do
28
+ note = described_class.new(content: "Test note")
29
+ expect(note.to_h).to eq({ content: "Test note" })
30
+ end
31
+ end
32
+
33
+ describe "#==" do
34
+ it "returns true for notes with same content" do
35
+ note1 = described_class.new(content: "Same content")
36
+ note2 = described_class.new(content: "Same content")
37
+ expect(note1 == note2).to be_truthy
38
+ end
39
+
40
+ it "returns false for notes with different content" do
41
+ note1 = described_class.new(content: "Content 1")
42
+ note2 = described_class.new(content: "Content 2")
43
+ expect(note1 == note2).to be_falsey
44
+ end
45
+
46
+ it "returns false for non-Note objects" do
47
+ note = described_class.new(content: "Test")
48
+ expect(note == "Test").to be_falsey
49
+ end
50
+ end
51
+
52
+ describe "#hash" do
53
+ it "generates same hash for equal notes" do
54
+ note1 = described_class.new(content: "Test")
55
+ note2 = described_class.new(content: "Test")
56
+ expect(note1.hash).to eq(note2.hash)
57
+ end
58
+
59
+ it "generates different hash for different notes" do
60
+ note1 = described_class.new(content: "Test 1")
61
+ note2 = described_class.new(content: "Test 2")
62
+ expect(note1.hash).not_to eq(note2.hash)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Recipe do
6
+ let(:ingredient) { Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g") }
7
+ let(:cookware) { Cooklang::Cookware.new(name: "pan") }
8
+ let(:timer) { Cooklang::Timer.new(duration: 5, unit: "minutes") }
9
+ let(:step) { Cooklang::Step.new(segments: ["Mix the ", { type: :ingredient, name: "flour" }]) }
10
+ let(:metadata) { Cooklang::Metadata.new(title: "Test Recipe") }
11
+
12
+ describe "#initialize" do
13
+ it "creates a recipe with all components" do
14
+ recipe = described_class.new(
15
+ ingredients: [ingredient],
16
+ cookware: [cookware],
17
+ timers: [timer],
18
+ steps: [step],
19
+ metadata: metadata
20
+ )
21
+
22
+ expect(recipe.ingredients).to eq([ingredient])
23
+ expect(recipe.cookware).to eq([cookware])
24
+ expect(recipe.timers).to eq([timer])
25
+ expect(recipe.steps).to eq([step])
26
+ expect(recipe.metadata).to eq(metadata)
27
+ end
28
+
29
+ it "freezes arrays to prevent modification" do
30
+ recipe = described_class.new(
31
+ ingredients: [ingredient],
32
+ cookware: [cookware],
33
+ timers: [timer],
34
+ steps: [step],
35
+ metadata: metadata
36
+ )
37
+
38
+ expect(recipe.ingredients).to be_frozen
39
+ expect(recipe.cookware).to be_frozen
40
+ expect(recipe.timers).to be_frozen
41
+ expect(recipe.steps).to be_frozen
42
+ end
43
+ end
44
+
45
+ describe "#ingredients_hash" do
46
+ it "returns ingredients as a hash with quantity and unit" do
47
+ ingredient1 = Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g")
48
+ ingredient2 = Cooklang::Ingredient.new(name: "salt", quantity: 1, unit: "pinch")
49
+
50
+ recipe = described_class.new(
51
+ ingredients: [ingredient1, ingredient2],
52
+ cookware: [],
53
+ timers: [],
54
+ steps: [],
55
+ metadata: Cooklang::Metadata.new
56
+ )
57
+
58
+ expected = {
59
+ "flour" => { quantity: 125, unit: "g" },
60
+ "salt" => { quantity: 1, unit: "pinch" }
61
+ }
62
+
63
+ expect(recipe.ingredients_hash).to eq(expected)
64
+ end
65
+
66
+ it "omits nil values" do
67
+ ingredient = Cooklang::Ingredient.new(name: "salt")
68
+
69
+ recipe = described_class.new(
70
+ ingredients: [ingredient],
71
+ cookware: [],
72
+ timers: [],
73
+ steps: [],
74
+ metadata: Cooklang::Metadata.new
75
+ )
76
+
77
+ expect(recipe.ingredients_hash).to eq({ "salt" => {} })
78
+ end
79
+ end
80
+
81
+ describe "#steps_text" do
82
+ it "returns text representation of steps" do
83
+ step1 = Cooklang::Step.new(segments: ["Mix the ingredients"])
84
+ step2 = Cooklang::Step.new(segments: ["Cook for ", { type: :timer, name: "timer" }])
85
+
86
+ recipe = described_class.new(
87
+ ingredients: [],
88
+ cookware: [],
89
+ timers: [],
90
+ steps: [step1, step2],
91
+ metadata: Cooklang::Metadata.new
92
+ )
93
+
94
+ expect(recipe.steps_text).to eq(["Mix the ingredients", "Cook for timer"])
95
+ end
96
+ end
97
+
98
+ describe "#to_h" do
99
+ it "returns complete hash representation" do
100
+ recipe = described_class.new(
101
+ ingredients: [ingredient],
102
+ cookware: [cookware],
103
+ timers: [timer],
104
+ steps: [step],
105
+ metadata: metadata
106
+ )
107
+
108
+ hash = recipe.to_h
109
+
110
+ expect(hash[:ingredients]).to eq([ingredient.to_h])
111
+ expect(hash[:cookware]).to eq([cookware.to_h])
112
+ expect(hash[:timers]).to eq([timer.to_h])
113
+ expect(hash[:steps]).to eq([step.to_h])
114
+ expect(hash[:metadata]).to eq(metadata.to_h)
115
+ end
116
+ end
117
+
118
+ describe "#==" do
119
+ it "returns true for recipes with same content" do
120
+ recipe1 = described_class.new(
121
+ ingredients: [ingredient],
122
+ cookware: [cookware],
123
+ timers: [timer],
124
+ steps: [step],
125
+ metadata: metadata
126
+ )
127
+
128
+ recipe2 = described_class.new(
129
+ ingredients: [ingredient],
130
+ cookware: [cookware],
131
+ timers: [timer],
132
+ steps: [step],
133
+ metadata: metadata
134
+ )
135
+
136
+ expect(recipe1).to eq(recipe2)
137
+ end
138
+
139
+ it "returns false for recipes with different content" do
140
+ recipe1 = described_class.new(
141
+ ingredients: [ingredient],
142
+ cookware: [],
143
+ timers: [],
144
+ steps: [],
145
+ metadata: Cooklang::Metadata.new
146
+ )
147
+
148
+ recipe2 = described_class.new(
149
+ ingredients: [],
150
+ cookware: [cookware],
151
+ timers: [],
152
+ steps: [],
153
+ metadata: Cooklang::Metadata.new
154
+ )
155
+
156
+ expect(recipe1).not_to eq(recipe2)
157
+ end
158
+
159
+ it "returns false for non-Recipe objects" do
160
+ recipe = described_class.new(
161
+ ingredients: [],
162
+ cookware: [],
163
+ timers: [],
164
+ steps: [],
165
+ metadata: Cooklang::Metadata.new
166
+ )
167
+
168
+ expect(recipe).not_to eq("not a recipe")
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Section do
6
+ describe "#initialize" do
7
+ it "creates section with name" do
8
+ section = described_class.new(name: "Preparation")
9
+ expect(section.name).to eq("Preparation")
10
+ end
11
+
12
+ it "converts name to string and freezes it" do
13
+ section = described_class.new(name: 123)
14
+ expect(section.name).to eq("123")
15
+ expect(section.name).to be_frozen
16
+ end
17
+ end
18
+
19
+ describe "#to_s" do
20
+ it "returns name" do
21
+ section = described_class.new(name: "Cooking")
22
+ expect(section.to_s).to eq("Cooking")
23
+ end
24
+ end
25
+
26
+ describe "#to_h" do
27
+ it "returns hash representation" do
28
+ section = described_class.new(name: "Preparation")
29
+ expect(section.to_h).to eq({ name: "Preparation", steps: [] })
30
+ end
31
+ end
32
+
33
+ describe "#==" do
34
+ it "returns true for sections with same name" do
35
+ section1 = described_class.new(name: "Prep")
36
+ section2 = described_class.new(name: "Prep")
37
+ expect(section1 == section2).to be_truthy
38
+ end
39
+
40
+ it "returns false for sections with different names" do
41
+ section1 = described_class.new(name: "Prep")
42
+ section2 = described_class.new(name: "Cook")
43
+ expect(section1 == section2).to be_falsey
44
+ end
45
+
46
+ it "returns false for non-Section objects" do
47
+ section = described_class.new(name: "Prep")
48
+ expect(section == "Prep").to be_falsey
49
+ end
50
+ end
51
+
52
+ describe "#hash" do
53
+ it "generates same hash for equal sections" do
54
+ section1 = described_class.new(name: "Test")
55
+ section2 = described_class.new(name: "Test")
56
+ expect(section1.hash).to eq(section2.hash)
57
+ end
58
+
59
+ it "generates different hash for different sections" do
60
+ section1 = described_class.new(name: "Test 1")
61
+ section2 = described_class.new(name: "Test 2")
62
+ expect(section1.hash).not_to eq(section2.hash)
63
+ end
64
+ end
65
+ end