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
@@ -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