├── fuzz ├── .gitignore ├── fuzz_targets │ └── fuzz_parser.rs └── Cargo.toml ├── bindings ├── src │ ├── uniffi-bindgen.rs │ └── aisle.rs ├── uniffi.toml ├── .cargo │ └── config.toml ├── Cargo.toml └── build-swift.sh ├── units ├── README.md └── spanish.toml ├── typescript ├── vitest.config.ts ├── tsconfig.json ├── Cargo.toml ├── build.rs ├── test │ ├── types.test.ts │ ├── recipe.test.ts │ ├── htmlRenderer.test.ts │ └── helpers.test.ts ├── fix-wasm-start.cjs ├── pkg-types.d.ts ├── package.json ├── README.md ├── PUBLISHING.md └── MIGRATION.md ├── .gitignore ├── playground ├── vite.config.ts ├── tsconfig.json ├── README.md ├── package.json ├── styles.css ├── index.html └── main.ts ├── .github └── workflows │ ├── typescript.yml │ ├── bindings.yml │ ├── preview.yml │ ├── playground.yml │ └── rust.yml ├── tests ├── fractions.rs ├── serde.rs ├── gen_canonical_test.py ├── pantry_test.rs ├── frontmatter_tests.rs ├── canonical.rs ├── general_tests.rs └── scale.rs ├── README.md ├── package.json ├── LICENSE ├── examples ├── cook.rs └── time.rs ├── src ├── parser │ ├── frontmatter.rs │ ├── token_stream.rs │ ├── section.rs │ ├── text_block.rs │ ├── metadata.rs │ ├── model.rs │ └── block_parser.rs ├── span.rs ├── lexer │ └── cursor.rs ├── located.rs ├── ast.rs ├── analysis │ └── mod.rs ├── scale.rs ├── text.rs └── lib.rs ├── Package.swift ├── Cargo.toml ├── benches ├── parse.rs ├── convert.rs ├── complex_test_recipe.cook ├── frontmatter_test_recipe.cook └── test_recipe.cook ├── swift └── Tests │ └── CooklangParserTests │ └── Demo.swift ├── units.toml ├── extensions.md └── CHANGELOG.md /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | corpus 3 | artifacts 4 | coverage 5 | -------------------------------------------------------------------------------- /bindings/src/uniffi-bindgen.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | uniffi::uniffi_bindgen_main() 3 | } 4 | -------------------------------------------------------------------------------- /units/README.md: -------------------------------------------------------------------------------- 1 | # Units files 2 | 3 | Units that are not bundled with the parser but may be useful. 4 | 5 | ## Translations 6 | - [español (spanish)](./spanish.toml) -------------------------------------------------------------------------------- /typescript/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import wasm from "vite-plugin-wasm"; 3 | 4 | export default defineConfig({ 5 | plugins: [wasm()], 6 | }); 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .DS_Store 3 | out/ 4 | .idea 5 | .build 6 | 7 | # Node 8 | node_modules 9 | .wireit 10 | dist 11 | 12 | # TypeScript compiled output 13 | typescript/index.js 14 | typescript/index.d.ts 15 | -------------------------------------------------------------------------------- /bindings/uniffi.toml: -------------------------------------------------------------------------------- 1 | [bindings.kotlin] 2 | package_name = "org.cooklang.parser" 3 | generate_immutable_records = true 4 | android = true 5 | 6 | [bindings.swift] 7 | ffi_module_name = "CooklangParserFFI" 8 | module_name = "CooklangParser" 9 | generate_immutable_records = true 10 | -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import wasm from "vite-plugin-wasm"; 3 | 4 | export default defineConfig({ 5 | plugins: [wasm()], 6 | optimizeDeps: { 7 | exclude: ["@cooklang/cooklang"], 8 | }, 9 | build: { 10 | target: "esnext", 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/fuzz_parser.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | use cooklang::{Converter, CooklangParser, Extensions}; 6 | 7 | fuzz_target!(|contents: &str| { 8 | let parser = CooklangParser::new(Extensions::all(), Converter::default()); 9 | let _ = parser.parse(&contents); 10 | }); 11 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "lib": ["dom", "es2016"], 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "skipLibCheck": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /playground/README.md: -------------------------------------------------------------------------------- 1 | # `cooklang-rs` playground 2 | 3 | To run, ensure that you have Rust and Node >v20 installed. 4 | 5 | From the root after `npm i`, run the following to watch for changes and reload the playground. 6 | 7 | ```sh 8 | npm run watch 9 | ``` 10 | 11 | This runs `wasm-pack` as required and Vitejs will reload the local development server as needed. 12 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cooklang-fuzz" 3 | version = "0.0.0" 4 | publish = false 5 | edition = "2021" 6 | 7 | [package.metadata] 8 | cargo-fuzz = true 9 | 10 | [dependencies] 11 | libfuzzer-sys = "0.4" 12 | 13 | [dependencies.cooklang] 14 | path = ".." 15 | 16 | [[bin]] 17 | name = "fuzz_parser" 18 | path = "fuzz_targets/fuzz_parser.rs" 19 | test = false 20 | doc = false 21 | bench = false 22 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cooklang-playground", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "scripts": { 7 | "start": "vite", 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "@cooklang/cooklang": "*" 12 | }, 13 | "devDependencies": { 14 | "typescript": "^5.8.3", 15 | "vite": "^6.3.5", 16 | "vite-plugin-wasm": "^3.4.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/typescript.yml: -------------------------------------------------------------------------------- 1 | name: Typescript 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 22 16 | - run: npm ci 17 | - name: test pkg 18 | run: npm test 19 | working-directory: ./typescript 20 | -------------------------------------------------------------------------------- /typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "target": "es2015", 5 | "lib": [ 6 | "es2015", 7 | "dom" 8 | ], 9 | "strict": true, 10 | "declaration": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "moduleResolution": "NodeNext" 15 | }, 16 | "include": ["index.ts"], 17 | "exclude": ["node_modules", "pkg"] 18 | } -------------------------------------------------------------------------------- /.github/workflows/bindings.yml: -------------------------------------------------------------------------------- 1 | name: Bindings 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | 13 | defaults: 14 | run: 15 | working-directory: ./bindings 16 | 17 | jobs: 18 | build: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Build 25 | run: cargo build --verbose 26 | - name: Run tests 27 | run: cargo test --verbose 28 | -------------------------------------------------------------------------------- /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | name: Preview 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | typescript: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | - run: npm ci 21 | - name: Publish Preview Versions 22 | run: npx pkg-pr-new publish './typescript' --template './playground' 23 | -------------------------------------------------------------------------------- /bindings/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-linux-android] 2 | linker = "x86_64-linux-android24-clang" 3 | rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] 4 | 5 | [target.i686-linux-android] 6 | linker = "i686-linux-android24-clang" 7 | rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] 8 | 9 | [target.armv7-linux-androideabi] 10 | linker = "armv7a-linux-androideabi24-clang" 11 | rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] 12 | 13 | [target.aarch64-linux-android] 14 | linker = "aarch64-linux-android24-clang" 15 | rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] 16 | -------------------------------------------------------------------------------- /typescript/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cooklang-wasm" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [lib] 8 | crate-type = ["cdylib"] 9 | 10 | [dependencies] 11 | cooklang = { path = "..", features = ["default", "ts"] } 12 | wasm-bindgen = "0.2.87" 13 | ansi-to-html = "0.2.1" 14 | serde = { version = "1.0", features = ["derive"] } 15 | serde_json = "1" 16 | serde-wasm-bindgen = "0.6" 17 | maud = "0.26.0" 18 | tsify = "0.5" 19 | serde_yaml = "0.9.34" 20 | 21 | [build-dependencies] 22 | git2 = { version = "0.20", default-features = false } 23 | 24 | [package.metadata.wasm-pack.profile.release] 25 | wasm-opt = false 26 | -------------------------------------------------------------------------------- /tests/fractions.rs: -------------------------------------------------------------------------------- 1 | #![cfg(feature = "bundled_units")] 2 | 3 | use cooklang::{convert::System, Converter, Quantity, Value}; 4 | use test_case::test_case; 5 | 6 | #[test_case(2.0, "tsp" => "2 tsp")] 7 | #[test_case(3.0, "tsp" => "1 tbsp")] 8 | #[test_case(3.5, "tsp" => "3 1/2 tsp")] 9 | #[test_case(15.0, "tsp" => "5 tbsp")] 10 | #[test_case(16.0, "tsp" => "1/3 c")] 11 | #[test_case(180.0, "C" => "356 °F")] 12 | #[test_case(499.999, "lb" => "500 lb")] 13 | #[test_case(1.5, "F" => "1.5 °F")] 14 | fn imperial(value: f64, unit: &str) -> String { 15 | let converter = Converter::bundled(); 16 | let mut q = Quantity::new(Value::from(value), Some(unit.to_string())); 17 | let _ = q.convert(System::Imperial, &converter); 18 | q.to_string() 19 | } 20 | -------------------------------------------------------------------------------- /tests/serde.rs: -------------------------------------------------------------------------------- 1 | //! Checks if serializing and deserialing a recipe with all the possible 2 | //! features end with with an equal recipe 3 | //! 4 | //! Deserializing a recipe with serde is not recommended as it 5 | //! will use more memory in comparison to using the cooklang parser 6 | 7 | use cooklang::parse; 8 | 9 | const RECIPE: &str = r#" 10 | 11 | >> description: desc 12 | >> time: 3 min 13 | 14 | A step with @ingredients{}. References to @&ingredients{}, #cookware, 15 | ~timers{3%min}. 16 | 17 | "#; 18 | 19 | #[test] 20 | fn serde_test() { 21 | let recipe = parse(RECIPE).into_output().unwrap(); 22 | 23 | let serialized = serde_json::to_string(&recipe).unwrap(); 24 | let deserialized = serde_json::from_str(&serialized).unwrap(); 25 | 26 | assert_eq!(recipe, deserialized); 27 | } 28 | -------------------------------------------------------------------------------- /typescript/build.rs: -------------------------------------------------------------------------------- 1 | fn get_version() -> String { 2 | if let Ok(version) = std::env::var("BUILD_COOKLANG_RS_VERSION") { 3 | return version; 4 | } 5 | 6 | if let Ok(repo) = git2::Repository::discover(".") { 7 | let mut options = git2::DescribeOptions::new(); 8 | options.describe_tags().show_commit_oid_as_fallback(true); 9 | if let Ok(describe) = repo.describe(&options) { 10 | if let Ok(format) = describe.format(None) { 11 | return format; 12 | } 13 | } 14 | } 15 | 16 | "".to_string() 17 | } 18 | 19 | fn main() { 20 | let out_dir = std::env::var_os("OUT_DIR").unwrap(); 21 | let dest_path = std::path::Path::new(&out_dir).join("version"); 22 | std::fs::write(&dest_path, get_version()).unwrap(); 23 | } 24 | -------------------------------------------------------------------------------- /typescript/test/types.test.ts: -------------------------------------------------------------------------------- 1 | import {it, expectTypeOf, expect} from "vitest"; 2 | import {Parser} from "../index.js"; 3 | import type {ScaledRecipeWithReport} from "../index.js"; 4 | import {Metadata} from "../pkg/cooklang_wasm.js"; 5 | 6 | it("generates Recipe type", async () => { 7 | const parser = new Parser(); 8 | const recipeRaw = "this could be a recipe"; 9 | const recipe = parser.parse(recipeRaw); 10 | expectTypeOf(recipe).toEqualTypeOf(); 11 | }); 12 | 13 | it("generates raw metadata", async () => { 14 | const parser = new Parser(); 15 | const recipeRaw = ` 16 | --- 17 | title: value 18 | --- 19 | aaa bbb 20 | `; 21 | const recipe = parser.parse(recipeRaw); 22 | expectTypeOf(recipe.recipe.raw_metadata).toEqualTypeOf(); 23 | expect(recipe.recipe.raw_metadata.map["title"]).equals("value"); 24 | }); 25 | -------------------------------------------------------------------------------- /units/spanish.toml: -------------------------------------------------------------------------------- 1 | [si.prefixes] 2 | kilo = [] 3 | hecto = [] 4 | deca = [] 5 | deci = [] 6 | centi = [] 7 | milli = ["mili"] 8 | 9 | [extend.units] 10 | l = { names = ["litro", "litros"] } 11 | c = { names = ["taza", "tazas"] } 12 | "fl oz" = { names = ["onza líquida", "onzas líquidas"] } 13 | gal = { names = ["galón", "galones"] } 14 | pint = { names = ["pinta", "pintas"] } 15 | quart = { names = ["cuarto", "cuartos"] } 16 | m = { names = ["metro", "metros"] } 17 | foot = { names = ["pie", "pies"] } 18 | inch = { names = ["pulgada", "pulgadas"] } 19 | gram = { names = ["gramo", "gramos"] } 20 | kilogram = { aliases = ["kilo", "kilos"] } 21 | ounce = { names = ["onza", "onzas"] } 22 | pound = { names = ["libra", "libras"] } 23 | s = { names = ["segundo", "segundos"] } 24 | min = { names = ["minuto", "minutos"] } 25 | h = { names = ["hora", "horas"] } 26 | d = { names = ["día", "días"] } 27 | -------------------------------------------------------------------------------- /bindings/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cooklang-bindings" 3 | version = "0.17.4" 4 | edition = "2021" 5 | authors = ["dubadub "] 6 | description = "Cooklang Uniffi bindings" 7 | license = "MIT" 8 | keywords = ["cooklang", "unuffi"] 9 | repository = "https://github.com/cooklang/cooklang-rs" 10 | readme = "README.md" 11 | 12 | [dependencies] 13 | anyhow = "1.0" 14 | cooklang = { path = "..", default-features = false, features = ["aisle"] } 15 | uniffi = "0.28.1" 16 | clap_derive = { version = "4.0.0-rc.1" } 17 | 18 | [lib] 19 | crate-type = ["cdylib", "staticlib"] 20 | 21 | [[bin]] 22 | # workaround: https://mozilla.github.io/uniffi-rs/tutorial/foreign_language_bindings.html#creating-the-bindgen-binary 23 | # This can be whatever name makes sense for your project, but the rest of this tutorial assumes uniffi-bindgen. 24 | name = "uniffi-bindgen" 25 | path = "src/uniffi-bindgen.rs" 26 | required-features = ["uniffi/cli"] 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cooklang-rs 2 | 3 | [![crates.io](https://img.shields.io/crates/v/cooklang)](https://crates.io/crates/cooklang) 4 | [![docs.rs](https://img.shields.io/docsrs/cooklang)](https://docs.rs/cooklang/) 5 | [![license](https://img.shields.io/crates/l/cooklang)](./LICENSE) 6 | 7 | Cooklang parser in rust with opt-in extensions. 8 | 9 | **All regular cooklang files parse as the same recipe**, the extensions are a 10 | superset of the original cooklang format. Also, the **extensions can be turned 11 | off**, so the parser can be used for regular cooklang if you don't like them. 12 | 13 | You can see a detailed list of all extensions explained [here](./extensions.md). 14 | 15 | The parser also includes: 16 | 17 | - Rich error report with annotated code spans. ([like this 18 | one](https://github.com/Zheoni/cooklang-chef/blob/main/images/error_report.png)) 19 | - Unit conversion. 20 | - Recipe scaling. 21 | - A parser for cooklang aisle configuration file. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cooklang-workspace", 3 | "private": true, 4 | "workspaces": [ 5 | "typescript", 6 | "playground" 7 | ], 8 | "scripts": { 9 | "playground": "wireit" 10 | }, 11 | "devDependencies": { 12 | "wireit": "^0.14.12" 13 | }, 14 | "wireit": { 15 | "playground": { 16 | "command": "npm run -w cooklang-playground start", 17 | "service": true, 18 | "dependencies": [ 19 | { 20 | "script": "watch-wasm", 21 | "cascade": false 22 | } 23 | ] 24 | }, 25 | "watch-wasm": { 26 | "command": "npm run -w @cooklang/cooklang watch-wasm", 27 | "clean": true, 28 | "packageLocks": [ 29 | "Cargo.lock", 30 | "package-lock.json" 31 | ], 32 | "files": [ 33 | "!target", 34 | "**/*.toml", 35 | "**/*.rs" 36 | ], 37 | "output": [ 38 | "typescript/pkg/**" 39 | ] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/playground.yml: -------------------------------------------------------------------------------- 1 | name: Playground 2 | 3 | on: 4 | release: 5 | types: [created] 6 | workflow_dispatch: 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | deploy-playground: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | 20 | - name: Git describe 21 | id: ghd 22 | uses: proudust/gh-describe@v1 23 | 24 | - run: npm ci 25 | - name: Build 26 | run: | 27 | cd playground 28 | npx vite build --base=/cooklang-rs/ 29 | env: 30 | BUILD_COOKLANG_RS_VERSION: ${{ steps.ghd.outputs.describe }} 31 | 32 | - name: Deploy 33 | uses: peaceiris/actions-gh-pages@v3 34 | if: github.ref == 'refs/heads/main' 35 | with: 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | publish_dir: ./playground/dist 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Francisco J. Sanchez 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. -------------------------------------------------------------------------------- /typescript/fix-wasm-start.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Post-build script to fix the __wbindgen_start call in the generated WASM wrapper. 4 | * The function may not exist depending on build configuration, causing runtime errors. 5 | */ 6 | 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | 10 | const wasmFile = path.join(__dirname, 'pkg', 'cooklang_wasm.js'); 11 | 12 | try { 13 | let content = fs.readFileSync(wasmFile, 'utf8'); 14 | 15 | // Replace unconditional __wbindgen_start call with conditional 16 | const before = 'wasm.__wbindgen_start();'; 17 | const after = `// Only call __wbindgen_start if it exists (it may not be exported for all build configurations) 18 | if (typeof wasm.__wbindgen_start === 'function') { 19 | wasm.__wbindgen_start(); 20 | }`; 21 | 22 | if (content.includes(before)) { 23 | content = content.replace(before, after); 24 | fs.writeFileSync(wasmFile, content, 'utf8'); 25 | console.log('✅ Fixed __wbindgen_start call in pkg/cooklang_wasm.js'); 26 | } else { 27 | console.log('ℹ️ No __wbindgen_start call found (already patched or not needed)'); 28 | } 29 | } catch (error) { 30 | console.error('❌ Error patching pkg/cooklang_wasm.js:', error.message); 31 | process.exit(1); 32 | } 33 | -------------------------------------------------------------------------------- /examples/cook.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | 3 | fn main() -> Result<(), Box> { 4 | let mut args = std::env::args(); 5 | let bin = args.next().unwrap(); 6 | let in_file = match args.next() { 7 | Some(path) => path, 8 | None => panic!("Usage: {bin} [|STDIN] [output_file|STDOUT]"), 9 | }; 10 | let out_file: Option> = match args.next().as_deref() { 11 | Some("STDOUT") => Some(Box::new(std::io::stdout().lock())), 12 | Some(path) => Some(Box::new(std::fs::File::create(path)?)), 13 | None => None, 14 | }; 15 | 16 | let input = match in_file.as_ref() { 17 | "STDIN" => { 18 | let mut buf = String::new(); 19 | std::io::stdin().lock().read_to_string(&mut buf)?; 20 | buf 21 | } 22 | path => std::fs::read_to_string(path)?, 23 | }; 24 | 25 | match cooklang::parse(&input).into_result() { 26 | Ok((recipe, warnings)) => { 27 | warnings.eprint(&in_file, &input, true)?; 28 | if let Some(mut out) = out_file { 29 | writeln!(out, "{recipe:#?}")?; 30 | } 31 | } 32 | Err(e) => { 33 | e.eprint(&in_file, &input, true)?; 34 | Err("failed to parse")?; 35 | } 36 | } 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /examples/time.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | use cooklang::CooklangParser; 4 | 5 | fn main() -> Result<(), Box> { 6 | let mut args = std::env::args(); 7 | let bin = args.next().unwrap(); 8 | let in_file = match args.next() { 9 | Some(path) => path, 10 | None => panic!("Usage: {bin} [extended] [n]"), 11 | }; 12 | let extended = match args.next().as_deref() { 13 | Some("true") => true, 14 | Some("false") => false, 15 | Some(_) => panic!("extended must be true or false"), 16 | None => true, 17 | }; 18 | let n = match args.next() { 19 | Some(s) => s.parse::()?, 20 | None => 100, 21 | }; 22 | 23 | let input = std::fs::read_to_string(&in_file)?; 24 | 25 | let parser = if extended { 26 | println!("extended parser"); 27 | CooklangParser::extended() 28 | } else { 29 | println!("canonical parser"); 30 | CooklangParser::canonical() 31 | }; 32 | 33 | // warmup 34 | for _ in 0..n { 35 | parser.parse(&input); 36 | } 37 | 38 | let start = Instant::now(); 39 | for _ in 0..n { 40 | parser.parse(&input); 41 | } 42 | let elapsed = start.elapsed(); 43 | 44 | println!("{n} runs"); 45 | println!("{} us", (elapsed.as_nanos() / n) as f64 / 1000.0); 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /src/parser/frontmatter.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub struct FrontMatterSplit<'i> { 3 | pub yaml_text: &'i str, 4 | pub yaml_offset: usize, 5 | pub cooklang_text: &'i str, 6 | pub cooklang_offset: usize, 7 | } 8 | 9 | const YAML_FENCE: &str = "---"; 10 | 11 | pub fn parse_frontmatter(input: &str) -> Option> { 12 | let mut fences = fences(input, YAML_FENCE); 13 | let (_, yaml_start) = fences.next()?; 14 | let (yaml_end, cooklang_start) = fences.next()?; 15 | let yaml_text = &input[yaml_start..yaml_end]; 16 | let cooklang_text = &input[cooklang_start..]; 17 | Some(FrontMatterSplit { 18 | yaml_text, 19 | yaml_offset: yaml_start, 20 | cooklang_text, 21 | cooklang_offset: cooklang_start, 22 | }) 23 | } 24 | 25 | fn lines_with_offset(s: &str) -> impl Iterator { 26 | let mut offset = 0; 27 | s.split_inclusive('\n').map(move |l| { 28 | let l_offset = offset; 29 | offset += l.len(); 30 | (l, l_offset) 31 | }) 32 | } 33 | 34 | fn fences<'a>(s: &'a str, fence: &'static str) -> impl Iterator + 'a { 35 | let lines = lines_with_offset(s); 36 | lines.filter_map(move |(line, offset)| { 37 | if line.trim_end() == fence { 38 | let fence_start = offset; 39 | let fence_end = offset + line.len(); 40 | return Some((fence_start, fence_end)); 41 | } 42 | None 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Setup cache 18 | uses: actions/cache@v4 19 | with: 20 | path: | 21 | ~/.cargo/bin/ 22 | ~/.cargo/registry/index/ 23 | ~/.cargo/registry/cache/ 24 | ~/.cargo/git/db/ 25 | target/ 26 | key: ${{ runner.os }}-cargo-stable-${{ hashFiles('**/Cargo.lock') }} 27 | - name: Build 28 | run: cargo build --verbose 29 | - name: Run tests 30 | run: cargo test --verbose 31 | 32 | fuzz: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Setup cache 37 | uses: actions/cache@v4 38 | with: 39 | path: | 40 | ~/.cargo/bin/ 41 | ~/.cargo/registry/index/ 42 | ~/.cargo/registry/cache/ 43 | ~/.cargo/git/db/ 44 | target/ 45 | key: ${{ runner.os }}-cargo-nightly-${{ hashFiles('**/Cargo.lock') }} 46 | - name: Use nightly toolchain 47 | run: rustup default nightly 48 | - name: Install cargo fuzz 49 | run: cargo fuzz --version || cargo install --locked cargo-fuzz 50 | - name: Fuzz parser 51 | run: cargo fuzz run --release fuzz_parser -- -max_total_time=120 -jobs=2 52 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | import PackageDescription 3 | import class Foundation.ProcessInfo 4 | 5 | var package = Package( 6 | name: "cooklang-rs", 7 | platforms: [ 8 | .macOS(.v10_15), 9 | .iOS(.v15), 10 | ], 11 | products: [ 12 | .library( 13 | name: "CooklangParser", 14 | targets: ["CooklangParser"]), 15 | ], 16 | dependencies: [ 17 | ], 18 | targets: [ 19 | .target( 20 | name: "CooklangParser", 21 | path: "swift/Sources/CooklangParser"), 22 | .testTarget( 23 | name: "CooklangParserTests", 24 | dependencies: ["CooklangParser"], 25 | path: "swift/Tests/CooklangParserTests"), 26 | .binaryTarget( 27 | name: "CooklangParserFFI", 28 | url: "https://github.com/cooklang/cooklang-rs/releases/download/v0.17.4/CooklangParserFFI.xcframework.zip", 29 | checksum: "145f07a7c60c2a9932bfa05ddd21540b600258ea697dbbfe37bb6f095b5855cb"), 30 | ] 31 | ) 32 | 33 | let cooklangParserTarget = package.targets.first(where: { $0.name == "CooklangParser" }) 34 | 35 | if ProcessInfo.processInfo.environment["USE_LOCAL_XCFRAMEWORK"] == nil { 36 | cooklangParserTarget?.dependencies.append("CooklangParserFFI") 37 | } else { 38 | package.targets.append(.binaryTarget( 39 | name: "CooklangParserFFI_local", 40 | path: "bindings/out/CooklangParserFFI.xcframework")) 41 | 42 | cooklangParserTarget?.dependencies.append("CooklangParserFFI_local") 43 | } 44 | -------------------------------------------------------------------------------- /typescript/pkg-types.d.ts: -------------------------------------------------------------------------------- 1 | // Type declarations for WASM package internals 2 | // These are provided for consumers who need direct access to the WASM bindings 3 | 4 | declare module '@cooklang/cooklang/pkg/cooklang_wasm_bg.wasm' { 5 | const wasmModule: WebAssembly.Module | (() => Promise); 6 | export default wasmModule; 7 | } 8 | 9 | declare module '@cooklang/cooklang/pkg/cooklang_wasm_bg.js' { 10 | export class Parser { 11 | constructor(); 12 | parse(input: string, scale?: number | null): any; 13 | group_ingredients(recipe: any): any; 14 | group_cookware(recipe: any): any; 15 | load_units: boolean; 16 | extensions: number; 17 | } 18 | 19 | export function __wbg_set_wasm(wasm: any): void; 20 | export function __wbindgen_init_externref_table(): void; 21 | export function __wbindgen_is_undefined(arg0: any): boolean; 22 | export function __wbindgen_string_get(arg0: any, arg1: any): any; 23 | export function __wbg_parse_def2e24ef1252aff(): any; 24 | export function __wbg_stringify_f7ed6987935b4a24(): any; 25 | export function __wbindgen_throw(arg0: any, arg1: any): void; 26 | } 27 | 28 | declare module '@cooklang/cooklang/pkg/cooklang_wasm.js' { 29 | export function initSync(module: any): void; 30 | export default function init(module: any): Promise; 31 | 32 | export class Parser { 33 | parse(input: string, scale?: number | null): any; 34 | group_ingredients(raw: any): any; 35 | group_cookware(raw: any): any; 36 | load_units: boolean; 37 | extensions: number; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cooklang/cooklang", 3 | "version": "0.17.4", 4 | "license": "MIT", 5 | "description": "Official Cooklang parser - Rust-powered WASM implementation for high performance recipe parsing", 6 | "type": "module", 7 | "main": "index.js", 8 | "types": "index.d.ts", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/cooklang/cooklang-rs.git", 12 | "directory": "typescript" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/cooklang/cooklang-rs/issues" 16 | }, 17 | "homepage": "https://cooklang.org", 18 | "files": [ 19 | "index.js", 20 | "index.d.ts", 21 | "pkg-types.d.ts", 22 | "fix-wasm-start.cjs", 23 | "pkg/cooklang_wasm_bg.wasm", 24 | "pkg/cooklang_wasm_bg.wasm.d.ts", 25 | "pkg/cooklang_wasm_bg.js", 26 | "pkg/cooklang_wasm.d.ts", 27 | "pkg/cooklang_wasm.js", 28 | "README.md", 29 | "MIGRATION.md" 30 | ], 31 | "keywords": [ 32 | "cooklang", 33 | "parser", 34 | "recipe", 35 | "cooking", 36 | "markup", 37 | "wasm", 38 | "rust", 39 | "typescript", 40 | "javascript" 41 | ], 42 | "scripts": { 43 | "build": "tsc", 44 | "build-wasm": "wasm-pack build --target bundler && node fix-wasm-start.cjs", 45 | "watch-wasm": "wasm-pack build --dev --mode no-install --target bundler && node fix-wasm-start.cjs", 46 | "prepare": "npm run build-wasm && npm run build", 47 | "test": "vitest" 48 | }, 49 | "devDependencies": { 50 | "typescript": "^5.7.2", 51 | "vite-plugin-wasm": "^3.4.1", 52 | "vitest": "^3.2.3", 53 | "wasm-pack": "^0.13.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/gen_canonical_test.py: -------------------------------------------------------------------------------- 1 | # Auto generate `canonical_cases/mod.rs` from `canonical.yaml` 2 | # Run this when updating `caonical.yaml` 3 | 4 | import yaml 5 | import sys 6 | import shutil 7 | import os 8 | 9 | try: 10 | from yaml import CLoader as YamlLoader, CDumper as YamlDumper 11 | except ImportError: 12 | from yaml import Loader as YamlLoader, Dumper as YamlDumper 13 | 14 | 15 | def main(): 16 | with open("canonical.yaml", encoding="utf-8") as input_file: 17 | input_tests = yaml.load(input_file, Loader=YamlLoader) 18 | print(f"version {input_tests['version']}", file=sys.stderr) 19 | tests = input_tests["tests"] 20 | print(f"loaded {len(tests)} tests", file=sys.stderr) 21 | 22 | try: 23 | shutil.rmtree("canonical_cases") 24 | except FileNotFoundError: 25 | pass 26 | os.mkdir("canonical_cases") 27 | 28 | with open("canonical_cases/mod.rs", "w", encoding="utf-8") as out: 29 | out.write(TEMPLATE_PRE) 30 | for name, test in tests.items(): 31 | if name.startswith("test"): 32 | name = name[4:] 33 | test_case = yaml.dump( 34 | test, 35 | Dumper=YamlDumper, 36 | allow_unicode=True, 37 | ) 38 | out.write(f'#[test_case(r#"\n{test_case}"#\n; "{name}")]\n') 39 | out.write(TEMPLATE_POS) 40 | 41 | 42 | TEMPLATE_PRE = """//! AUTO GENERATED WITH `gen_canonical_tests.py` 43 | use test_case::test_case; 44 | use super::{runner, TestCase}; 45 | """ 46 | TEMPLATE_POS = """fn canonical(input: &str) { 47 | let test_case: TestCase = serde_yaml::from_str(input).expect("Bad YAML input"); 48 | runner(test_case); 49 | } 50 | """ 51 | 52 | if __name__ == "__main__": 53 | main() 54 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cooklang" 3 | version = "0.17.4" 4 | edition = "2021" 5 | authors = ["Zheoni "] 6 | description = "Cooklang parser with opt-in extensions" 7 | license = "MIT" 8 | keywords = ["cooklang", "cooking", "recipes"] 9 | categories = ["parser-implementations"] 10 | repository = "https://github.com/cooklang/cooklang-rs" 11 | readme = "README.md" 12 | 13 | [dependencies] 14 | bitflags = { version = "2", features = ["serde"] } 15 | serde = { version = "1", features = ["derive", "rc"] } 16 | strum = { version = "0.26.1", features = ["derive"] } 17 | thiserror = "2" 18 | enum-map = { version = "2", features = ["serde"] } 19 | tracing = "0.1" 20 | codesnake = "0.2.1" 21 | unicode-width = "0.2" 22 | finl_unicode = { version = "1.2", features = [ 23 | "categories", 24 | ], default-features = false } 25 | smallvec = { version = "1" } 26 | unicase = "2.7.0" 27 | yansi = "1.0.1" 28 | serde_yaml = "0.9.34" 29 | toml = { version = "0.8", optional = true } 30 | tsify = { version = "0.5", optional = true } 31 | wasm-bindgen = { version = "0.2", optional = true } 32 | 33 | [dev-dependencies] 34 | toml = "0.8" 35 | serde_json = "1" 36 | criterion = "0.5" 37 | test-case = "3.2.1" 38 | indoc = "2.0.3" 39 | 40 | [build-dependencies] 41 | toml = { version = "0.8", optional = true } 42 | prettyplease = { version = "0.2", optional = true } 43 | quote = { version = "1", optional = true } 44 | syn = { version = "2", optional = true } 45 | proc-macro2 = { version = "1", optional = true } 46 | 47 | [features] 48 | default = ["aisle", "bundled_units"] 49 | bundled_units = ["toml", "prettyplease", "quote", "syn", "proc-macro2"] 50 | aisle = [] 51 | pantry = ["toml"] 52 | ts = ["wasm-bindgen", "tsify"] 53 | 54 | [[bench]] 55 | name = "parse" 56 | harness = false 57 | 58 | [[bench]] 59 | name = "convert" 60 | harness = false 61 | 62 | [workspace] 63 | members = [".", "typescript", "bindings", "fuzz"] 64 | -------------------------------------------------------------------------------- /bindings/src/aisle.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use cooklang::aisle::Category as OriginalAisleCategory; 4 | 5 | /// An ingredient with its name and aliases for aisle categorization 6 | #[derive(uniffi::Record, Debug, Clone)] 7 | pub struct AisleIngredient { 8 | pub name: String, 9 | pub aliases: Vec, 10 | } 11 | 12 | /// Maps ingredient names to their category names for quick lookup 13 | pub type AisleReverseCategory = HashMap; 14 | 15 | /// A shopping aisle category containing related ingredients 16 | #[derive(uniffi::Record, Debug, Clone)] 17 | pub struct AisleCategory { 18 | pub name: String, 19 | pub ingredients: Vec, 20 | } 21 | 22 | /// Configuration for organizing ingredients into shopping aisles 23 | #[derive(uniffi::Object, Debug, Clone)] 24 | pub struct AisleConf { 25 | pub categories: Vec, // cache for quick category search 26 | pub cache: AisleReverseCategory, 27 | } 28 | 29 | #[uniffi::export] 30 | impl AisleConf { 31 | /// Returns the category name for a given ingredient 32 | /// 33 | /// # Arguments 34 | /// * `ingredient_name` - The name of the ingredient to categorize 35 | /// 36 | /// # Returns 37 | /// The category name if the ingredient is found, None otherwise 38 | pub fn category_for(&self, ingredient_name: String) -> Option { 39 | self.cache.get(&ingredient_name).cloned() 40 | } 41 | } 42 | 43 | pub fn into_category(original: &OriginalAisleCategory) -> AisleCategory { 44 | let mut ingredients: Vec = Vec::new(); 45 | 46 | original.ingredients.iter().for_each(|i| { 47 | let mut it = i.names.iter(); 48 | 49 | let name = it.next().unwrap().to_string(); 50 | let aliases: Vec = it.map(|v| v.to_string()).collect(); 51 | 52 | ingredients.push(AisleIngredient { name, aliases }); 53 | }); 54 | 55 | AisleCategory { 56 | name: original.name.to_string(), 57 | ingredients, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /benches/parse.rs: -------------------------------------------------------------------------------- 1 | use criterion::{criterion_group, criterion_main, Criterion}; 2 | 3 | use cooklang::{parser::PullParser, CooklangParser, Extensions}; 4 | 5 | const TEST_RECIPE: &str = include_str!("./test_recipe.cook"); 6 | const FRONTMATTER_RECIPE: &str = include_str!("./frontmatter_test_recipe.cook"); 7 | const COMPLEX_TEST_RECIPE: &str = include_str!("./complex_test_recipe.cook"); 8 | 9 | fn canonical(c: &mut Criterion) { 10 | let mut group = c.benchmark_group("canonical"); 11 | 12 | let canonical = CooklangParser::canonical(); 13 | let extended = CooklangParser::extended(); 14 | 15 | group.bench_with_input("parse-canonical", TEST_RECIPE, |b, input| { 16 | b.iter(|| canonical.parse(input).is_valid()) 17 | }); 18 | group.bench_with_input("parse-extended", TEST_RECIPE, |b, input| { 19 | b.iter(|| extended.parse(input).is_valid()) 20 | }); 21 | group.bench_with_input("tokens-canonical", TEST_RECIPE, |b, input| { 22 | b.iter(|| PullParser::new(input, Extensions::empty()).count()) 23 | }); 24 | group.bench_with_input("tokens-extended", TEST_RECIPE, |b, input| { 25 | b.iter(|| PullParser::new(input, Extensions::all()).count()) 26 | }); 27 | group.bench_with_input("meta", TEST_RECIPE, |b, input| { 28 | b.iter(|| extended.parse_metadata(input).is_valid()) 29 | }); 30 | group.bench_with_input("frontmatter_meta", FRONTMATTER_RECIPE, |b, input| { 31 | { 32 | b.iter(|| extended.parse_metadata(input).is_valid()) 33 | } 34 | }); 35 | } 36 | 37 | fn extended(c: &mut Criterion) { 38 | let parser = CooklangParser::extended(); 39 | 40 | let mut group = c.benchmark_group("extended"); 41 | 42 | group.bench_with_input("parse", COMPLEX_TEST_RECIPE, |b, input| { 43 | b.iter(|| parser.parse(input).is_valid()) 44 | }); 45 | group.bench_with_input("tokens", COMPLEX_TEST_RECIPE, |b, input| { 46 | b.iter(|| PullParser::new(input, Extensions::all()).count()) 47 | }); 48 | } 49 | 50 | criterion_group!(benches, canonical, extended); 51 | criterion_main!(benches); 52 | -------------------------------------------------------------------------------- /src/span.rs: -------------------------------------------------------------------------------- 1 | //! Utility to represent a location in the source code 2 | 3 | use std::ops::Range; 4 | 5 | /// Location in the source code 6 | /// 7 | /// The offsets are zero-indexed charactere offsets from the beginning of the source 8 | /// code. 9 | #[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, PartialOrd, Ord)] 10 | pub struct Span { 11 | start: usize, 12 | end: usize, 13 | } 14 | 15 | impl Span { 16 | pub(crate) fn new(start: usize, end: usize) -> Self { 17 | Self { start, end } 18 | } 19 | 20 | pub(crate) fn pos(pos: usize) -> Self { 21 | Self { 22 | start: pos, 23 | end: pos, 24 | } 25 | } 26 | 27 | /// Start offset of the span 28 | pub fn start(&self) -> usize { 29 | self.start 30 | } 31 | 32 | /// End (exclusive) offset of the span 33 | pub fn end(&self) -> usize { 34 | self.end 35 | } 36 | 37 | /// Get the span as a range 38 | pub fn range(&self) -> Range { 39 | self.start..self.end 40 | } 41 | 42 | /// Len of the span in bytes 43 | pub fn len(&self) -> usize { 44 | self.end - self.start 45 | } 46 | 47 | /// Check if the span is empty 48 | pub fn is_empty(&self) -> bool { 49 | self.start == self.end 50 | } 51 | } 52 | 53 | impl std::fmt::Debug for Span { 54 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 55 | write!(f, "{}..{}", self.start, self.end) 56 | } 57 | } 58 | 59 | impl From> for Span { 60 | fn from(value: Range) -> Self { 61 | Self::new(value.start, value.end) 62 | } 63 | } 64 | 65 | impl From for Range { 66 | fn from(value: Span) -> Self { 67 | value.start..value.end 68 | } 69 | } 70 | 71 | impl From> for Span { 72 | fn from(value: crate::located::Located) -> Self { 73 | value.span() 74 | } 75 | } 76 | 77 | impl crate::error::Recover for Span { 78 | fn recover() -> Self { 79 | Self::new(0, 0) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /typescript/test/recipe.test.ts: -------------------------------------------------------------------------------- 1 | import {it, expect} from "vitest"; 2 | import {CooklangParser} from "../index.js"; 3 | 4 | 5 | it("reads interpreted metadata", async () => { 6 | const input = ` 7 | --- 8 | title: Some title 9 | description: Some description 10 | tags: tag1,tag2 11 | author: Author 12 | source: 13 | name: wiki 14 | url: https://wikipedia.com 15 | course: dinner 16 | time: 3h 12min 17 | servings: 23 18 | difficulty: hard 19 | cuisine: norwegian 20 | diet: true 21 | image: example.png 22 | locale: en_US 23 | custom1: 44 24 | custom2: some string 25 | --- 26 | `; 27 | 28 | const parser = new CooklangParser(); 29 | const recipe = parser.parse(input)[0]; 30 | expect(recipe.title).toEqual("Some title"); 31 | expect(recipe.description).toEqual("Some description"); 32 | expect(recipe.tags).toEqual(new Set(["tag1", "tag2"])); 33 | expect(recipe.author).toEqual({name: "Author", url: null}); 34 | expect(recipe.source).toEqual({name: "wiki", url: "https://wikipedia.com"}); 35 | expect(recipe.course).toEqual("dinner"); 36 | expect(recipe.time).toEqual(192); 37 | expect(recipe.servings).toEqual(23); 38 | expect(recipe.difficulty).toEqual("hard"); 39 | expect(recipe.cuisine).toEqual("norwegian"); 40 | expect(recipe.diet).toEqual(true); 41 | expect(recipe.images).toEqual("example.png"); 42 | expect(recipe.locale).toEqual(["en", "US"]); 43 | expect(recipe.custom_metadata.get("custom1")).toEqual(44); 44 | expect(recipe.custom_metadata.get("custom2")).toEqual("some string"); 45 | }); 46 | 47 | it("reads data", async () => { 48 | const input = ` 49 | --- 50 | title: Some title 51 | --- 52 | A step. @ingredient #ware ~timer{2min} 53 | `; 54 | 55 | const parser = new CooklangParser(); 56 | const recipe = parser.parse(input)[0]; 57 | expect(recipe.rawMetadata).toEqual(new Map([ 58 | ["title", "Some title"], 59 | ])); 60 | expect(recipe.sections[0].content[0].type).toEqual("step"); 61 | expect(recipe.ingredients[0].name).toEqual("ingredient"); 62 | expect(recipe.cookware[0].name).toEqual("ware"); 63 | expect(recipe.timers[0].name).toEqual("timer"); 64 | expect(recipe.inlineQuantities).toEqual([]); 65 | }); -------------------------------------------------------------------------------- /benches/convert.rs: -------------------------------------------------------------------------------- 1 | use cooklang::{ 2 | convert::{ConvertTo, System, UnitsFile}, 3 | Converter, Quantity, Value, 4 | }; 5 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 6 | 7 | fn conversions(c: &mut Criterion) { 8 | let mut group = c.benchmark_group("conversions"); 9 | let converter = Converter::default(); 10 | 11 | let input = vec![ 12 | (1.5, "tsp"), 13 | (2.0, "tsp"), 14 | (3.0, "tsp"), 15 | (3.5, "tbsp"), 16 | (300.0, "ml"), 17 | (1.5, "l"), 18 | (20.0, "g"), 19 | ] 20 | .into_iter() 21 | .map(|(v, u)| Quantity::new(Value::Number(v.into()), Some(u.to_string()))) 22 | .collect::>(); 23 | 24 | let input = black_box(input); 25 | 26 | group.bench_with_input("fractions", &input, |b, input| { 27 | b.iter(|| { 28 | let mut input = input.clone(); 29 | for q in &mut input { 30 | let _ = q.convert(ConvertTo::Best(System::Imperial), &converter); 31 | let _ = q.fit(&converter); 32 | } 33 | }) 34 | }); 35 | group.bench_with_input("regular", &input, |b, input| { 36 | b.iter(|| { 37 | let mut input = input.clone(); 38 | for q in &mut input { 39 | let _ = q.convert(ConvertTo::Best(System::Metric), &converter); 40 | let _ = q.fit(&converter); 41 | } 42 | }) 43 | }); 44 | } 45 | 46 | fn bundled_units(c: &mut Criterion) { 47 | let mut group = c.benchmark_group("bundled_units"); 48 | group.bench_function("get_bundled", |b| { 49 | b.iter_batched( 50 | || {}, 51 | |_| UnitsFile::bundled(), 52 | criterion::BatchSize::NumIterations((100_000_000 / size_of::()) as u64), 53 | ); 54 | }); 55 | group.bench_function("parse_toml", |b| { 56 | b.iter_batched( 57 | || {}, 58 | |_| toml::from_str::(include_str!("../units.toml")), 59 | criterion::BatchSize::NumIterations((100_000_000 / size_of::()) as u64), 60 | ); 61 | }); 62 | } 63 | 64 | criterion_group!(benches, conversions, bundled_units); 65 | criterion_main!(benches); 66 | -------------------------------------------------------------------------------- /src/lexer/cursor.rs: -------------------------------------------------------------------------------- 1 | use std::str::Chars; 2 | 3 | /// Peekable iterator from a &str 4 | /// 5 | /// This was adapted from 6 | pub struct Cursor<'a> { 7 | len_remaining: usize, 8 | chars: Chars<'a>, 9 | #[cfg(debug_assertions)] 10 | prev: char, 11 | } 12 | 13 | pub(crate) const EOF_CHAR: char = '\0'; 14 | 15 | impl<'a> Cursor<'a> { 16 | pub fn new(input: &'a str) -> Self { 17 | Self { 18 | len_remaining: input.len(), 19 | chars: input.chars(), 20 | #[cfg(debug_assertions)] 21 | prev: EOF_CHAR, 22 | } 23 | } 24 | 25 | /// Returns the last eaten symbol. 26 | pub(crate) fn prev(&self) -> char { 27 | #[cfg(debug_assertions)] 28 | { 29 | self.prev 30 | } 31 | #[cfg(not(debug_assertions))] 32 | { 33 | EOF_CHAR 34 | } 35 | } 36 | 37 | /// Peeks the next char. If none, [`EOF_CHAR`] is returned. But EOF should 38 | /// be checked with [`Self::is_eof`]. 39 | pub(crate) fn first(&self) -> char { 40 | // cloning chars is cheap as it's only a view into memory 41 | self.chars.clone().next().unwrap_or(EOF_CHAR) 42 | } 43 | 44 | /// Checks if there is more input to consume. 45 | pub(crate) fn is_eof(&self) -> bool { 46 | self.chars.as_str().is_empty() 47 | } 48 | 49 | /// Returns amount of already consumed symbols. 50 | pub(crate) fn pos_within_token(&self) -> u32 { 51 | (self.len_remaining - self.chars.as_str().len()) as u32 52 | } 53 | 54 | /// Resets the number of bytes consumed to 0. 55 | pub(crate) fn reset_pos_within_token(&mut self) { 56 | self.len_remaining = self.chars.as_str().len(); 57 | } 58 | 59 | /// Moves to the next character. 60 | pub(crate) fn bump(&mut self) -> Option { 61 | let c = self.chars.next()?; 62 | #[cfg(debug_assertions)] 63 | { 64 | self.prev = c; 65 | } 66 | Some(c) 67 | } 68 | 69 | /// Eats symbols while predicate returns true or until the end of file is reached. 70 | pub(crate) fn eat_while(&mut self, mut predicate: impl FnMut(char) -> bool) { 71 | while predicate(self.first()) && !self.is_eof() { 72 | self.bump(); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/parser/token_stream.rs: -------------------------------------------------------------------------------- 1 | //! [Cursor](crate::lexer::Cursor) iterator adapter for it's use in 2 | //! `Parser(super::parser::Parser`). 3 | 4 | pub use crate::lexer::TokenKind; 5 | use crate::{lexer::Cursor, span::Span}; 6 | 7 | pub struct TokenStream<'i> { 8 | cursor: Cursor<'i>, 9 | consumed: usize, 10 | } 11 | 12 | impl<'i> TokenStream<'i> { 13 | pub fn new(input: &'i str) -> Self { 14 | Self { 15 | cursor: Cursor::new(input), 16 | consumed: 0, 17 | } 18 | } 19 | 20 | pub fn offset(&mut self, offset: usize) { 21 | self.consumed += offset; 22 | } 23 | } 24 | 25 | impl Iterator for TokenStream<'_> { 26 | type Item = Token; 27 | 28 | fn next(&mut self) -> Option { 29 | let t = self.cursor.advance_token(); 30 | let start = self.consumed; 31 | self.consumed += t.len as usize; 32 | if t.kind == TokenKind::Eof && self.cursor.is_eof() { 33 | None 34 | } else { 35 | Some(Token { 36 | kind: t.kind, 37 | span: Span::new(start, self.consumed), 38 | }) 39 | } 40 | } 41 | } 42 | 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 44 | pub struct Token { 45 | pub kind: TokenKind, 46 | pub span: Span, 47 | } 48 | 49 | impl Token { 50 | pub fn len(&self) -> usize { 51 | self.span.len() 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | macro_rules! tokens { 57 | ($($kind:tt . $len:expr),*) => {{ 58 | let mut v = Vec::new(); 59 | let mut _len = 0; 60 | $( 61 | v.push($crate::parser::token_stream::Token { kind: $crate::lexer::T![$kind], span: $crate::span::Span::new(_len, _len + $len) }); 62 | _len += $len; 63 | )* 64 | v 65 | }}; 66 | } 67 | #[cfg(test)] 68 | pub(crate) use tokens; 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | use crate::lexer::T; 73 | 74 | use super::*; 75 | 76 | #[test] 77 | fn tokens_macro() { 78 | let t = tokens![word.3, ws.1]; 79 | assert_eq!( 80 | t, 81 | vec![ 82 | Token { 83 | kind: T![word], 84 | span: Span::new(0, 3) 85 | }, 86 | Token { 87 | kind: T![ws], 88 | span: Span::new(3, 4) 89 | }, 90 | ] 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /playground/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | flex-direction: column; 4 | font-family: "Noto Sans", sans-serif; 5 | } 6 | 7 | h1 { 8 | margin-inline: 2rem; 9 | margin-block: 0; 10 | } 11 | 12 | .code-panes { 13 | display: flex; 14 | height: 85vh; 15 | gap: 1rem; 16 | align-items: stretch; 17 | justify-content: stretch; 18 | } 19 | 20 | .code-panes>* { 21 | flex: 1; 22 | width: 50%; 23 | } 24 | 25 | .codeblock, 26 | #editor { 27 | border: 1px solid; 28 | } 29 | 30 | .output-pane { 31 | display: flex; 32 | gap: 0.5rem; 33 | flex-direction: column; 34 | } 35 | 36 | #output { 37 | flex-grow: 2; 38 | flex-shrink: 1; 39 | } 40 | 41 | #errors-details { 42 | flex-shrink: 1; 43 | } 44 | 45 | pre { 46 | font-family: "JetBrains Mono", monospace; 47 | } 48 | 49 | .codeblock { 50 | overflow-y: scroll; 51 | overflow-x: auto; 52 | padding: 1rem; 53 | background-color: #f0f0f0; 54 | font-size: 14px; 55 | margin: 0; 56 | } 57 | 58 | pre#errors { 59 | background-color: #151715; 60 | color: white; 61 | max-height: 45vh; 62 | } 63 | 64 | #controls { 65 | margin-inline: 1rem; 66 | } 67 | 68 | #controls .inline { 69 | display: flex; 70 | gap: 1rem; 71 | align-items: baseline; 72 | justify-content: flex-end; 73 | } 74 | 75 | #extra .container { 76 | display: flex; 77 | gap: 1rem; 78 | } 79 | 80 | #extra fieldset { 81 | width: fit-content; 82 | } 83 | 84 | details { 85 | padding: 0.5em 0.5em 0; 86 | } 87 | 88 | summary { 89 | font-weight: bold; 90 | margin: -0.5em -0.5em 0; 91 | padding: 0.5em; 92 | } 93 | 94 | details[open] { 95 | padding: 0.5em; 96 | } 97 | 98 | .narrow { 99 | margin-left: auto; 100 | width: fit-content; 101 | text-align: end; 102 | } 103 | 104 | fieldset { 105 | text-align: start; 106 | } 107 | 108 | .codeblock .ingredient { 109 | color: green; 110 | font-weight: bold; 111 | } 112 | 113 | .codeblock .timer { 114 | color: teal; 115 | font-weight: bold; 116 | } 117 | 118 | .codeblock .cookware { 119 | color: orange; 120 | font-weight: bold; 121 | } 122 | 123 | .codeblock .temp { 124 | color: crimson; 125 | font-weight: bold; 126 | } 127 | 128 | .codeblock ul { 129 | margin: 0; 130 | padding: 0; 131 | } 132 | 133 | .codeblock li.metadata { 134 | list-style: none; 135 | } 136 | 137 | .metadata .key { 138 | font-weight: bold; 139 | color: green; 140 | } -------------------------------------------------------------------------------- /typescript/test/htmlRenderer.test.ts: -------------------------------------------------------------------------------- 1 | import {it, expect} from "vitest"; 2 | import {CooklangParser, HTMLRenderer} from "../index.js"; 3 | 4 | it("renders metadata", async () => { 5 | const input = ` 6 | --- 7 | title: Some title 8 | description: Some description 9 | --- 10 | `; 11 | const output = "
    " + 12 | "" + 13 | "" + 14 | "

"; 15 | 16 | const parser = new CooklangParser(); 17 | const recipe = parser.parse(input)[0]; 18 | const renderer = new HTMLRenderer(); 19 | expect(renderer.render(recipe)).toEqual(output); 20 | }); 21 | 22 | it("renders ingredients", async () => { 23 | const input = ` 24 | @cat{3}(black) 25 | `; 26 | const output = "

Ingredients:

  • cat: 3 (black)

1. cat(3)

"; 27 | 28 | const parser = new CooklangParser(); 29 | const recipe = parser.parse(input)[0]; 30 | const renderer = new HTMLRenderer(); 31 | expect(renderer.render(recipe)).toEqual(output); 32 | }); 33 | 34 | it("renders cookware", async () => { 35 | const input = ` 36 | #cauldron{3}(magic) 37 | `; 38 | const output = "

Cookware:

  • cauldron: 3 (magic)

1. cauldron(3)

"; 39 | 40 | const parser = new CooklangParser(); 41 | const recipe = parser.parse(input)[0]; 42 | const renderer = new HTMLRenderer(); 43 | expect(renderer.render(recipe)).toEqual(output); 44 | }); 45 | 46 | it("renders timer", async () => { 47 | const input = ` 48 | ~eon{5min} 49 | `; 50 | const output = "

1. (eon)5min

"; 51 | 52 | const parser = new CooklangParser(); 53 | const recipe = parser.parse(input)[0]; 54 | const renderer = new HTMLRenderer(); 55 | expect(renderer.render(recipe)).toEqual(output); 56 | }); 57 | 58 | it("renders sections and steps", async () => { 59 | const input = ` 60 | === aaa 61 | a 62 | 63 | b 64 | 65 | === bbb 66 | c 67 | 68 | d 69 | `; 70 | const output = "

(1) aaa

1. a

2. b

(2) bbb

1. c

2. d

"; 71 | 72 | const parser = new CooklangParser(); 73 | const recipe = parser.parse(input)[0]; 74 | const renderer = new HTMLRenderer(); 75 | expect(renderer.render(recipe)).toEqual(output); 76 | }); -------------------------------------------------------------------------------- /benches/complex_test_recipe.cook: -------------------------------------------------------------------------------- 1 | -- this uses the cooklang-rs extensions 2 | 3 | >> emoji: 🌶️ 4 | >> source: https://www.gimmesomeoven.com/best-chicken-enchiladas-ever/print-recipe/59596/ 5 | >> tags: mejicano, picante 6 | >> description: De las comidas más satisfactorias que he comido. 7 | >> [duplicate]: reference 8 | 9 | >> [mode]: ingredients 10 | 11 | - @queso|mezcla de quesos mejicanos{} 12 | - @tortilla|tortilla de trigo{}(grande) 13 | - @aceite|aceite de aguacata{}(Sí, se puede usar de oliva) 14 | 15 | >> [mode]: default 16 | 17 | = Salsa 18 | 19 | Calienta el @aceite{2%tbsp} en un #cazo pequeño{} a fuego 20 | medio-alto. 21 | 22 | Añade @harina{2%tbsp} y cocina durante ~{1%min} sin parar de remover. Añade el 23 | @@polvo de chile{1/4%cup}, @polvo de ajo{1/2%tsp} @comino molido{1/2%tsp}, 24 | @orégano{1/4%tsp} y sigue cocinando durante ~{1%min} sin parar de remover. 25 | 26 | > A mi me gusta más con caldo de verduras, pero caldo de pollo también está bueno. 27 | 28 | Agrega el @caldo de verdura{2%cups} poco a poco removiendo para que no se formen 29 | grumos. Sigue cocinando hasta que hierva y luego baja el fuego a medio-bajo 30 | para que reduzca durante ~{10-15%min}, sin cubrir, hasta que haya espesado un poco. 31 | 32 | Añade @sal al gusto. 33 | 34 | = Enchiladas 35 | 36 | Precalienta el #horno a 350 ºF. 37 | 38 | > Para mi son muchos chiles, sustituyo la mitad o más por pimiento verde normal. 39 | 40 | Pela y corta en dados la @cebolla{1}(pequeña). En dados los @chiles verdes{4%oz}. 41 | En dados de 1,5 cm la @pechuga de pollo{1 1/2}(deshuesado y sin piel). 42 | 43 | En una #sartén grande{}, calienta @aceite{2%tbsp} a fuego 44 | medio-alto. Añade la @cebolla y sofrie durante ~{3%min}, moviendo de vez en cuando. 45 | 46 | Añade los @chiles verdes|pimientos{} y el @pollo. Añade @sal y @pimienta al gusto. 47 | Sofríe durante ~{6-8%min} o hasta que el pollo se haya cocinado por completo. 48 | 49 | Añade las @judías negras{15%oz} ya cocidas y mezcla todo. Aparta del fuego. 50 | 51 | Engrasa una #bandeja|bandeja de horno{20x30 cm}. 52 | 53 | Prepara una línea de montaje de enchiladas: 54 | @tortilla{8}, @&(=1)salsa, la mezcla de pollo y @queso{3%cup}. Pon una @tortilla, 55 | luego 2 cucharadas de salsa, generosa mezcla de pollo en el centro y luego unas 56 | 4 cucharadas de @queso. Enrolla y directamente a la #bandeja. 57 | Ve poniéndolas una al lado de otra, debería llenar la bandeja al completo. Al 58 | final, echa toda la salsa y el @queso que sobre. 59 | 60 | Hornear sin tapar durante ~{20%min}, hasta que estén completamente cocidas y 61 | algo crujientes afuera. 62 | 63 | Acompañar de @?queso cotija{}, @?queso feta{}, @?cebolla roja{}, 64 | @?aguacate{}, @?crema agria{}, @?cilantro{}, @?limón{}, @?lima{} o lo que guste, 65 | pero no dejes que se enfríe. -------------------------------------------------------------------------------- /swift/Tests/CooklangParserTests/Demo.swift: -------------------------------------------------------------------------------- 1 | import CooklangParser 2 | import XCTest 3 | 4 | class Demo: XCTestCase { 5 | func test_demo() { 6 | let recipe = """ 7 | Preheat the oven to 180C. 8 | 9 | Mix @flour{2%cups} with @baking powder{1%tsp}. 10 | 11 | Add @eggs{2} and stir for ~{2%minutes}. 12 | """ 13 | 14 | let parsed = CooklangParser.parseRecipe(input: recipe) 15 | 16 | let expected = CooklangRecipe( 17 | metadata: [:], 18 | steps: [ 19 | CooklangParser.Step(items: [CooklangParser.Item.text(value: "Preheat the oven to 180C.")]), 20 | CooklangParser.Step(items: [ 21 | CooklangParser.Item.text(value: "Mix "), 22 | CooklangParser.Item.ingredient( 23 | name: "flour", 24 | amount: Optional( 25 | CooklangParser.Amount( 26 | quantity: CooklangParser.Value.number(value: 2.0), units: Optional("cups")))), 27 | CooklangParser.Item.text(value: " with "), 28 | CooklangParser.Item.ingredient( 29 | name: "baking powder", 30 | amount: Optional( 31 | CooklangParser.Amount( 32 | quantity: CooklangParser.Value.number(value: 1.0), units: Optional("tsp")))), 33 | CooklangParser.Item.text(value: "."), 34 | ]), 35 | CooklangParser.Step(items: [ 36 | CooklangParser.Item.text(value: "Add "), 37 | CooklangParser.Item.ingredient( 38 | name: "eggs", 39 | amount: Optional( 40 | CooklangParser.Amount(quantity: CooklangParser.Value.number(value: 2.0), units: nil))), 41 | CooklangParser.Item.text(value: " and stir for "), 42 | CooklangParser.Item.timer( 43 | name: nil, 44 | amount: Optional( 45 | CooklangParser.Amount( 46 | quantity: CooklangParser.Value.number(value: 2.0), units: Optional("minutes")))), 47 | CooklangParser.Item.text(value: "."), 48 | ]), 49 | ], 50 | ingredients: [ 51 | "baking powder": [ 52 | CooklangParser.GroupedQuantityKey( 53 | name: "tsp", unitType: CooklangParser.QuantityType.number): 54 | CooklangParser.Value.number(value: 1.0) 55 | ], 56 | "eggs": [ 57 | CooklangParser.GroupedQuantityKey(name: "", unitType: CooklangParser.QuantityType.number): 58 | CooklangParser.Value.number(value: 2.0) 59 | ], 60 | "flour": [ 61 | CooklangParser.GroupedQuantityKey( 62 | name: "cups", unitType: CooklangParser.QuantityType.number): 63 | CooklangParser.Value.number(value: 2.0) 64 | ], 65 | ], 66 | cookware: [] 67 | ) 68 | 69 | XCTAssertEqual(parsed, expected) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/pantry_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "pantry")] 2 | #[test] 3 | fn test_pantry_integration() { 4 | use cooklang::pantry; 5 | 6 | let input = r#" 7 | [freezer] 8 | ice_cream = "1%L" 9 | frozen_peas = "500%g" 10 | spinach = { bought = "05.05.2024", expire = "05.06.2024", quantity = "1%kg" } 11 | 12 | [fridge] 13 | milk = { expire = "10.05.2024", quantity = "2%L" } 14 | cheese = { expire = "15.05.2024" } 15 | 16 | [pantry] 17 | rice = "5%kg" 18 | pasta = "1%kg" 19 | flour = "5%kg" 20 | "#; 21 | 22 | let pantry_conf = pantry::parse(input).unwrap(); 23 | 24 | // Check sections 25 | assert_eq!(pantry_conf.sections.len(), 3); 26 | 27 | // Check freezer items (order not guaranteed due to HashMap) 28 | let freezer = &pantry_conf.sections["freezer"]; 29 | assert_eq!(freezer.len(), 3); 30 | 31 | // Find items by name since order isn't guaranteed 32 | let ice_cream = freezer.iter().find(|i| i.name() == "ice_cream").unwrap(); 33 | assert_eq!(ice_cream.quantity(), Some("1%L")); 34 | 35 | let frozen_peas = freezer.iter().find(|i| i.name() == "frozen_peas").unwrap(); 36 | assert_eq!(frozen_peas.quantity(), Some("500%g")); 37 | 38 | let spinach = freezer.iter().find(|i| i.name() == "spinach").unwrap(); 39 | assert_eq!(spinach.bought(), Some("05.05.2024")); 40 | assert_eq!(spinach.expire(), Some("05.06.2024")); 41 | assert_eq!(spinach.quantity(), Some("1%kg")); 42 | 43 | // Check fridge items 44 | let fridge = &pantry_conf.sections["fridge"]; 45 | assert_eq!(fridge.len(), 2); 46 | 47 | let milk = fridge.iter().find(|i| i.name() == "milk").unwrap(); 48 | assert_eq!(milk.expire(), Some("10.05.2024")); 49 | assert_eq!(milk.quantity(), Some("2%L")); 50 | 51 | let cheese = fridge.iter().find(|i| i.name() == "cheese").unwrap(); 52 | assert_eq!(cheese.expire(), Some("15.05.2024")); 53 | assert!(cheese.quantity().is_none()); 54 | 55 | // Check pantry items 56 | let pantry_section = &pantry_conf.sections["pantry"]; 57 | assert_eq!(pantry_section.len(), 3); 58 | 59 | let rice = pantry_section.iter().find(|i| i.name() == "rice").unwrap(); 60 | assert_eq!(rice.quantity(), Some("5%kg")); 61 | 62 | let pasta = pantry_section.iter().find(|i| i.name() == "pasta").unwrap(); 63 | assert_eq!(pasta.quantity(), Some("1%kg")); 64 | 65 | let flour = pantry_section.iter().find(|i| i.name() == "flour").unwrap(); 66 | assert_eq!(flour.quantity(), Some("5%kg")); 67 | 68 | // Test items_by_section method 69 | let items_map = pantry_conf.items_by_section(); 70 | assert_eq!(items_map.get("ice_cream"), Some(&"freezer")); 71 | assert_eq!(items_map.get("milk"), Some(&"fridge")); 72 | assert_eq!(items_map.get("rice"), Some(&"pantry")); 73 | 74 | // Test all_items method 75 | let all_items: Vec<_> = pantry_conf.all_items().collect(); 76 | assert_eq!(all_items.len(), 8); 77 | } 78 | -------------------------------------------------------------------------------- /benches/frontmatter_test_recipe.cook: -------------------------------------------------------------------------------- 1 | --- 2 | servings: 4 3 | emoji: 🥟 4 | tags: warm, fried, starter 5 | source: CooklangCLI Seed 6 | prep time: 20 min 7 | cook time: 30 min 8 | --- 9 | 10 | [- Same recipe, same metadata. Just in a YAML frontmatter now -] 11 | 12 | 13 | Let's first make some dough. Set kettle with some water and prepare 14 | @strong white flour{75%g} and @plain flour{75%g}. 15 | 16 | Put the flours into a bowl. Mix the @just-boiled water{75%ml} with the 17 | @salt{2%tsp}, then add this to the flour, mixing it in with a knife. It will 18 | seem very floury to start with but keep going and it will come together. Don’t 19 | be tempted to add more water. Cover with a damp cloth for 10 minutes, then 20 | remove and knead until the dough is smooth and elastic. Cover the dough again 21 | and leave it to stand somewhere warm for an hour. 22 | 23 | Cut the dough into 2 equal pieces and dust your work surface with flour. Roll 24 | the dough out as thinly as you can. It will be resistant to start with, but you 25 | will eventually end up with a round about 35cm in diameter and with a thickness 26 | of less than 1mm. 27 | 28 | Cut the dough into rounds using a 9cm cutter, then repeat with the other piece 29 | of dough. Knead the offcuts together and roll again. You should end up with at 30 | least 24 discs. Dust with flour in between each one if you want to stack them 31 | together. 32 | 33 | Heat the @olive oil{1%tbsp} in a frying pan and gently cook very finely shredded 34 | and chopped @Chinese cabbage{100%g}, very finely grated @carrot{50%g} and finely 35 | chopped @spring onions{3%items} until they have wilted down. Remove from the 36 | heat and stir in very finely chopped @strained kimchi{75%g}. Taste for seasoning 37 | and add salt and pepper if you think it necessary. Allow to cool. 38 | 39 | Assemble the dumplings by putting a teaspoon of filling in the centre of each 40 | wrapper. Wet around the sides – thoroughly, as they can sometimes crack – then 41 | pinch the edges together, pleating from the middle down each side to seal. 42 | 43 | Make the dipping sauce by mixing @soy sauce{2%tbsp}, @rice wine vinegar{1%tbsp}, 44 | @chilli oil{1%tbsp} and @sesame oil{1%tsp} together. Taste for seasoning and add 45 | salt if necessary. 46 | 47 | To cook, heat @olive oil{3%tbsp} in a non-stick frying pan that has a lid – you 48 | need just enough oil to thinly cover the base. Add some of the dumplings, making 49 | sure they are well spread out. 50 | 51 | Fry the dumplings over a medium heat until they are crisp and brown underneath, 52 | then add water – just enough to thinly cover the base of the pan. Cover the pan 53 | quickly, as it will spit when you add the water, and steam the dumplings for 5 54 | minutes, until they are starting to look translucent and the water has 55 | evaporated. Uncover and cook for a further minute to make sure the underside is 56 | still crisp. Remove and keep warm while you cook the rest. Serve hot with the 57 | dipping sauce. -------------------------------------------------------------------------------- /src/parser/section.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::label, lexer::T}; 2 | 3 | use super::{tokens_span, warning, BlockParser, Event}; 4 | 5 | pub(crate) fn section<'i>(block: &mut BlockParser<'_, 'i>) -> Option> { 6 | block.consume(T![=])?; 7 | block.consume_while(|t| t == T![=]); 8 | let name_pos = block.current_offset(); 9 | let name_tokens = block.consume_while(|t| t != T![=]); 10 | let name = block.text(name_pos, name_tokens); 11 | block.consume_while(|t| t == T![=]); 12 | block.ws_comments(); 13 | 14 | if !block.rest().is_empty() { 15 | block.warn( 16 | warning!( 17 | "A section block is invalid and it will be a step", 18 | label!(tokens_span(block.rest()), "remove this"), 19 | ) 20 | .hint("After the ending `=` the line must end for it to be a valid section"), 21 | ); 22 | return None; 23 | } 24 | 25 | let name = if name.is_text_empty() { 26 | None 27 | } else { 28 | Some(name) 29 | }; 30 | Some(Event::Section { name }) 31 | } 32 | 33 | #[cfg(test)] 34 | mod tests { 35 | use std::collections::VecDeque; 36 | 37 | use super::*; 38 | use crate::{ 39 | parser::{token_stream::TokenStream, BlockParser}, 40 | span::Span, 41 | Extensions, 42 | }; 43 | use test_case::test_case; 44 | 45 | macro_rules! text { 46 | ($s:expr; $offset:expr) => { 47 | text!($s; $offset, $offset + $s.len()) 48 | }; 49 | ($s:expr; $start:expr, $end:expr) => { 50 | Some(($s.to_string(), Span::new($start, $end))) 51 | }; 52 | } 53 | 54 | #[test_case("= section" => text!(" section"; 1); "single char")] 55 | #[test_case("== section ==" => text!(" section "; 2) ; "fenced")] 56 | #[test_case("=" => None ; "no name single char")] 57 | #[test_case("===" => None ; "no name multiple char")] 58 | #[test_case("= ==" => None ; "no name unbalanced")] 59 | #[test_case("= = ==" => panics "failed to parse section" ; "more than one split")] 60 | #[test_case("== section == " => text!(" section "; 2) ; "trailing whitespace")] 61 | #[test_case("== section == -- comment " => text!(" section "; 2) ; "trailing line comment")] 62 | #[test_case("== section == [- comment -] " => text!(" section "; 2) ; "trailing block comment")] 63 | #[test_case("== section [- and a comment = -] ==" => text!(" section "; 2, 33) ; "in between block comment")] 64 | #[test_case("== section -- and a comment" => text!(" section "; 2) ; "in between line comment")] 65 | fn test_section(input: &'static str) -> Option<(String, Span)> { 66 | let tokens = TokenStream::new(input).collect::>(); 67 | let mut events = VecDeque::new(); 68 | let mut bp = BlockParser::new(&tokens, input, &mut events, Extensions::all()); 69 | let event = section(&mut bp).expect("failed to parse section"); 70 | bp.finish(); 71 | assert!(events.is_empty()); 72 | let Event::Section { name } = event else { 73 | panic!() 74 | }; 75 | name.map(|text| (text.text().into_owned(), text.span())) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /benches/test_recipe.cook: -------------------------------------------------------------------------------- 1 | [- 2 | from https://github.com/cooklang/CookCLI/blob/main/seed/Potstickers.cook 3 | 4 | added some metadata entries because cooklang-rs may take a longer time 5 | to further process some entries 6 | 7 | this should be parsed by any cooklang parser, so no extensions 8 | -] 9 | >> servings: 4 10 | >> emoji: 🥟 11 | >> tags: warm, fried, starter 12 | >> source: CooklangCLI Seed 13 | >> prep time: 20 min 14 | >> cook time: 30 min 15 | 16 | 17 | Let's first make some dough. Set kettle with some water and prepare 18 | @strong white flour{75%g} and @plain flour{75%g}. 19 | 20 | Put the flours into a bowl. Mix the @just-boiled water{75%ml} with the 21 | @salt{2%tsp}, then add this to the flour, mixing it in with a knife. It will 22 | seem very floury to start with but keep going and it will come together. Don’t 23 | be tempted to add more water. Cover with a damp cloth for 10 minutes, then 24 | remove and knead until the dough is smooth and elastic. Cover the dough again 25 | and leave it to stand somewhere warm for an hour. 26 | 27 | Cut the dough into 2 equal pieces and dust your work surface with flour. Roll 28 | the dough out as thinly as you can. It will be resistant to start with, but you 29 | will eventually end up with a round about 35cm in diameter and with a thickness 30 | of less than 1mm. 31 | 32 | Cut the dough into rounds using a 9cm cutter, then repeat with the other piece 33 | of dough. Knead the offcuts together and roll again. You should end up with at 34 | least 24 discs. Dust with flour in between each one if you want to stack them 35 | together. 36 | 37 | Heat the @olive oil{1%tbsp} in a frying pan and gently cook very finely shredded 38 | and chopped @Chinese cabbage{100%g}, very finely grated @carrot{50%g} and finely 39 | chopped @spring onions{3%items} until they have wilted down. Remove from the 40 | heat and stir in very finely chopped @strained kimchi{75%g}. Taste for seasoning 41 | and add salt and pepper if you think it necessary. Allow to cool. 42 | 43 | Assemble the dumplings by putting a teaspoon of filling in the centre of each 44 | wrapper. Wet around the sides – thoroughly, as they can sometimes crack – then 45 | pinch the edges together, pleating from the middle down each side to seal. 46 | 47 | Make the dipping sauce by mixing @soy sauce{2%tbsp}, @rice wine vinegar{1%tbsp}, 48 | @chilli oil{1%tbsp} and @sesame oil{1%tsp} together. Taste for seasoning and add 49 | salt if necessary. 50 | 51 | To cook, heat @olive oil{3%tbsp} in a non-stick frying pan that has a lid – you 52 | need just enough oil to thinly cover the base. Add some of the dumplings, making 53 | sure they are well spread out. 54 | 55 | Fry the dumplings over a medium heat until they are crisp and brown underneath, 56 | then add water – just enough to thinly cover the base of the pan. Cover the pan 57 | quickly, as it will spit when you add the water, and steam the dumplings for 5 58 | minutes, until they are starting to look translucent and the water has 59 | evaporated. Uncover and cook for a further minute to make sure the underside is 60 | still crisp. Remove and keep warm while you cook the rest. Serve hot with the 61 | dipping sauce. -------------------------------------------------------------------------------- /src/located.rs: -------------------------------------------------------------------------------- 1 | //! Utility to add location information to any type 2 | 3 | use std::{ 4 | fmt::{Debug, Display}, 5 | ops::{Deref, DerefMut, Range}, 6 | }; 7 | 8 | use serde::Serialize; 9 | 10 | use crate::{error::Recover, span::Span}; 11 | 12 | /// Wrapper type that adds location information to another 13 | #[derive(PartialEq, Serialize)] 14 | pub struct Located { 15 | inner: T, 16 | span: Span, 17 | } 18 | 19 | impl Located { 20 | /// Creata a new instance of [`Located`] 21 | pub fn new(inner: T, span: impl Into) -> Self { 22 | Self { 23 | inner, 24 | span: span.into(), 25 | } 26 | } 27 | 28 | /// Map the inner value while keeping the same location 29 | pub fn map(self, f: F) -> Located 30 | where 31 | F: FnOnce(T) -> O, 32 | { 33 | Located { 34 | inner: f(self.inner), 35 | span: self.span, 36 | } 37 | } 38 | 39 | /// Discard the location and consume the inner value 40 | pub fn into_inner(self) -> T { 41 | self.inner 42 | } 43 | 44 | /// Consume and get the inner value and it's location 45 | pub fn take_pair(self) -> (T, Span) { 46 | (self.inner, self.span) 47 | } 48 | 49 | /// Get a reference to the inner value 50 | pub fn value(&self) -> &T { 51 | &self.inner 52 | } 53 | 54 | /// Get the location 55 | pub fn span(&self) -> Span { 56 | self.span 57 | } 58 | } 59 | 60 | impl Copy for Located {} 61 | 62 | impl Located { 63 | /// Get the inner value by copy 64 | pub fn get(&self) -> T { 65 | self.inner 66 | } 67 | } 68 | 69 | impl Clone for Located 70 | where 71 | T: Clone, 72 | { 73 | fn clone(&self) -> Self { 74 | Self { 75 | inner: self.inner.clone(), 76 | span: self.span, 77 | } 78 | } 79 | } 80 | 81 | impl Debug for Located 82 | where 83 | T: Debug, 84 | { 85 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 86 | self.inner.fmt(f)?; 87 | f.write_str(" @ ")?; 88 | self.span.fmt(f) 89 | } 90 | } 91 | 92 | impl Display for Located 93 | where 94 | T: Display, 95 | { 96 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 97 | self.inner.fmt(f) 98 | } 99 | } 100 | 101 | impl Deref for Located { 102 | type Target = T; 103 | 104 | fn deref(&self) -> &Self::Target { 105 | &self.inner 106 | } 107 | } 108 | 109 | impl DerefMut for Located { 110 | fn deref_mut(&mut self) -> &mut Self::Target { 111 | &mut self.inner 112 | } 113 | } 114 | 115 | impl From> for Range { 116 | fn from(value: Located) -> Self { 117 | value.span.range() 118 | } 119 | } 120 | 121 | impl Recover for Located 122 | where 123 | T: Recover, 124 | { 125 | fn recover() -> Self { 126 | Self { 127 | inner: T::recover(), 128 | span: Recover::recover(), 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/ast.rs: -------------------------------------------------------------------------------- 1 | //! Abstract Syntax Tree representation of a cooklang recipe 2 | //! 3 | //! The [`Ast`] is generated by collecting the events generated by the 4 | //! [`parser`](crate::parser). Usually this is not necesary and the events can 5 | //! be directly passed to the analysis pass to be transformed into a 6 | //! [`Recipe`](crate::model::Recipe). So this is just an optional intermediate 7 | //! representation of the file and not a complete parsed recipe. 8 | 9 | use serde::Serialize; 10 | 11 | use crate::{ 12 | error::{PassResult, SourceReport}, 13 | parser::{Block, BlockKind, Event, Item}, 14 | }; 15 | 16 | /// Abstract syntax tree of a cooklang file 17 | /// 18 | /// The AST is (mostly) borrowed from the input and offers location information of each 19 | /// element back to the source file. 20 | #[derive(Debug, Serialize, Clone)] 21 | pub struct Ast<'a> { 22 | pub blocks: Vec>, 23 | } 24 | 25 | /// Builds an [`Ast`] given an [`Event`] iterator 26 | /// 27 | /// Probably the iterator you want is an instance of [`PullParser`](crate::parser::PullParser). 28 | #[tracing::instrument(level = "debug", skip_all)] 29 | pub fn build_ast<'i>(events: impl Iterator>) -> PassResult> { 30 | let mut blocks = Vec::new(); 31 | let mut items = Vec::new(); 32 | let mut ctx = SourceReport::empty(); 33 | for event in events { 34 | match event { 35 | Event::YAMLFrontMatter(_) => todo!(), 36 | Event::Metadata { key, value } => blocks.push(Block::Metadata { key, value }), 37 | Event::Section { name } => blocks.push(Block::Section { name }), 38 | Event::Start(_kind) => items.clear(), 39 | Event::End(kind) => { 40 | match kind { 41 | BlockKind::Step => { 42 | if !items.is_empty() { 43 | blocks.push(Block::Step { 44 | items: std::mem::take(&mut items), 45 | }) 46 | } 47 | } 48 | BlockKind::Text => { 49 | let texts = std::mem::take(&mut items) 50 | .into_iter() 51 | .map(|i| { 52 | if let Item::Text(t) = i { 53 | t 54 | } else { 55 | panic!("Not text in text block: {i:?}"); 56 | } 57 | }) 58 | .collect(); 59 | blocks.push(Block::TextBlock(texts)) 60 | } 61 | }; 62 | } 63 | Event::Text(t) => items.push(Item::Text(t)), 64 | Event::Ingredient(c) => items.push(Item::Ingredient(Box::new(c))), 65 | Event::Cookware(c) => items.push(Item::Cookware(Box::new(c))), 66 | Event::Timer(c) => items.push(Item::Timer(Box::new(c))), 67 | Event::Error(e) => ctx.push(e), 68 | Event::Warning(w) => ctx.push(w), 69 | } 70 | } 71 | let ast = Ast { blocks }; 72 | PassResult::new(Some(ast), ctx) 73 | } 74 | -------------------------------------------------------------------------------- /units.toml: -------------------------------------------------------------------------------- 1 | default_system = "metric" 2 | 3 | [si.prefixes] 4 | kilo = ["kilo"] 5 | hecto = ["hecto"] 6 | deca = ["deca"] 7 | deci = ["deci"] 8 | centi = ["centi"] 9 | milli = ["milli"] 10 | 11 | [si.symbol_prefixes] 12 | kilo = ["k"] 13 | hecto = ["h"] 14 | deca = ["da"] 15 | deci = ["d"] 16 | centi = ["c"] 17 | milli = ["m"] 18 | 19 | [fractions] 20 | metric = false 21 | imperial = true 22 | 23 | [fractions.quantity] 24 | time = false 25 | temperature = false 26 | 27 | [fractions.unit] 28 | tsp = { max_whole = 5, max_denominator = 8 } 29 | tbsp = { max_whole = 4, max_denominator = 3 } 30 | lb = { max_denominator = 8 } 31 | 32 | [[quantity]] 33 | quantity = "volume" 34 | best = { metric = ["ml", "l"], imperial = ["cup", "tsp", "tbsp"] } 35 | [quantity.units] 36 | metric = [ 37 | { names = ["liter", "liters", "litre", "litres"], symbols = ["l", "L"], ratio = 1, expand_si = true }, 38 | ] 39 | imperial = [ 40 | { names = ["teaspoon", "teaspoons"], symbols = ["tsp", "tsp."], ratio = 0.004_928_921 }, 41 | { names = ["tablespoon", "tablespoons"], symbols = ["tbsp", "tbsp.", "tbs", "tbs."], ratio = 0.014_786_764 }, 42 | { names = ["fluid ounce", "fluid ounces"], symbols = ["fl oz", "fl. oz.", "fl. oz", "fl oz."], ratio = 0.029_573_529 }, 43 | { names = ["cup", "cups"], symbols = ["c"], ratio = 0.236_588_236 }, 44 | { names = ["pint", "pints"], symbols = ["pt"], ratio = 0.473_176_473 }, 45 | { names = ["quart", "quarts"], symbols = ["qt"], ratio = 0.946_352_946 }, 46 | { names = ["gallon", "gallons"], symbols = ["gal"], ratio = 3.785_411_784 }, 47 | ] 48 | 49 | [[quantity]] 50 | quantity = "length" 51 | best = { metric = ["cm", "mm", "m"], imperial = ["in", "ft"] } 52 | [quantity.units] 53 | metric = [ 54 | { names = ["meter", "meters", "metre", "metres"], symbols = ["m"], ratio = 1, expand_si = true }, 55 | ] 56 | imperial = [ 57 | { names = ["foot", "feet"], symbols = ["ft", "'"], ratio = 0.3048 }, 58 | { names = ["inch", "inches"], symbols = ["in", "\""], ratio = 0.0254 }, 59 | ] 60 | 61 | [[quantity]] 62 | quantity = "mass" 63 | best = { metric = ["mg", "g", "kg"], imperial = ["oz", "lb"] } 64 | [quantity.units] 65 | metric = [ 66 | { names = ["gram", "grams"], symbols = ["g"], ratio = 1, expand_si = true }, 67 | ] 68 | imperial = [ 69 | { names = ["ounce", "ounces"], symbols = ["oz", "oz."], ratio = 28.349_523_125 }, 70 | { names = ["pound", "pounds"], symbols = ["lb", "lb."], ratio = 453.592_37 }, 71 | ] 72 | 73 | [[quantity]] 74 | quantity = "time" 75 | best = ["s", "h", "min", "d"] 76 | units = [ 77 | { names = ["second", "seconds"], symbols = ["s", "sec"], aliases = ["secs"], ratio = 1 }, 78 | { names = ["minute", "minutes"], symbols = ["min"], aliases = ["mins"], ratio = 60 }, 79 | { names = ["hour", "hours"], symbols = ["h"], ratio = 3600 }, 80 | { names = ["day", "days"], symbols = ["d"], ratio = 86400 }, 81 | ] 82 | 83 | [[quantity]] 84 | quantity = "temperature" 85 | best = { metric = ["C"], imperial = ["F"] } 86 | [quantity.units] 87 | metric = [ 88 | { names = ["celsius"], symbols = ["°C", "ºC", "℃", "C"], ratio = 1, difference = 273.15 }, 89 | ] 90 | imperial = [ 91 | { names = ["fahrenheit"], symbols = ["°F", "ºF", "℉", "F"], ratio = 0.55555555556, difference = 459.67 } 92 | ] -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cooklang playground 8 | 9 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 |
22 |

cooklang-rs playground

23 | 24 | 25 |
26 |
27 |
28 | Repo | 29 | Version: 30 |
[loading]
31 | | 32 |
33 | 34 | 42 |
43 | 44 | 45 |
46 | 50 |
51 |
52 |
53 | More options 54 | 55 |
56 |
57 | 58 | 59 |
60 | 61 |
62 | Extensions 63 |
64 |
65 |
66 |
67 |
68 |
69 | 70 |
71 |
72 |
73 |

74 |             
75 | Errors and warnings 76 |

77 |             
78 |
79 |
80 |
81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /src/parser/text_block.rs: -------------------------------------------------------------------------------- 1 | use crate::lexer::T; 2 | 3 | use super::{BlockKind, BlockParser, Event}; 4 | 5 | pub(crate) fn parse_text_block(bp: &mut BlockParser) { 6 | bp.event(Event::Start(BlockKind::Text)); 7 | 8 | while !bp.rest().is_empty() { 9 | // skip > and leading whitespace 10 | let _ = bp.consume(T![>]).and_then(|_| bp.consume(T![ws])); 11 | let start = bp.current_offset(); 12 | let tokens = bp.capture_slice(|bp| { 13 | bp.consume_while(|t| t != T![newline]); 14 | let _ = bp.consume(T![newline]); 15 | }); 16 | let text = bp.text(start, tokens); 17 | if !text.is_text_empty() { 18 | bp.event(Event::Text(text)); 19 | } 20 | } 21 | 22 | bp.event(Event::End(BlockKind::Text)); 23 | } 24 | 25 | #[cfg(test)] 26 | mod tests { 27 | use std::collections::VecDeque; 28 | 29 | use crate::{ 30 | error::SourceReport, 31 | parser::{mt, token_stream::TokenStream}, 32 | Extensions, 33 | }; 34 | 35 | use super::*; 36 | use indoc::indoc; 37 | use test_case::test_case; 38 | 39 | fn t(input: &str) -> (Vec, SourceReport) { 40 | let mut tokens = TokenStream::new(input).collect::>(); 41 | // trim trailing newlines, block splitting should make sure this never 42 | // reaches the step function 43 | while let Some(mt![newline]) = tokens.last() { 44 | tokens.pop(); 45 | } 46 | let mut events = VecDeque::new(); 47 | let mut bp = BlockParser::new(&tokens, input, &mut events, Extensions::all()); 48 | parse_text_block(&mut bp); 49 | bp.finish(); 50 | let mut other = Vec::new(); 51 | let mut ctx = SourceReport::empty(); 52 | 53 | for ev in events { 54 | match ev { 55 | Event::Error(err) | Event::Warning(err) => ctx.push(err), 56 | _ => other.push(ev), 57 | } 58 | } 59 | let [Event::Start(BlockKind::Text), items @ .., Event::End(BlockKind::Text)] = 60 | other.as_slice() 61 | else { 62 | panic!() 63 | }; 64 | (Vec::from(items), ctx) 65 | } 66 | 67 | #[test_case( 68 | indoc! { " 69 | > a text step 70 | with 2 lines 71 | " } 72 | => "a text step with 2 lines" 73 | ; "no second line marker" 74 | )] 75 | #[test_case( 76 | indoc! { " 77 | > a text step 78 | > with 2 lines 79 | " } 80 | => "a text step with 2 lines" 81 | ; "second line marker" 82 | )] 83 | #[test_case( 84 | indoc! { " 85 | > with no marker 86 | <- this ws stays 87 | " } 88 | => "with no marker <- this ws stays" 89 | ; "no trim if no marker" 90 | )] 91 | fn multiline_text_step(input: &str) -> String { 92 | let (events, ctx) = t(input); 93 | assert!(ctx.is_empty()); 94 | let mut text = String::new(); 95 | for e in events { 96 | match e { 97 | Event::Text(t) => text += &t.text(), 98 | _ => panic!("not text inside text step"), 99 | } 100 | } 101 | text 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /typescript/README.md: -------------------------------------------------------------------------------- 1 | # @cooklang/cooklang 2 | 3 | Official [Cooklang](https://cooklang.org) parser for JavaScript and TypeScript. 4 | 5 | This is a high-performance WASM implementation powered by the Rust [cooklang-rs](https://github.com/cooklang/cooklang-rs) parser. It provides fast, reliable recipe parsing with full support for the Cooklang specification. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm install @cooklang/cooklang 11 | ``` 12 | 13 | ## Quick Start 14 | 15 | ```typescript 16 | import { Parser } from '@cooklang/cooklang'; 17 | 18 | const parser = new Parser(); 19 | const recipe = parser.parse(` 20 | >> servings: 4 21 | 22 | Add @salt and @pepper to taste. 23 | Cook for ~{10%minutes}. 24 | `); 25 | 26 | console.log(recipe.sections[0].content); 27 | ``` 28 | 29 | ## Why WASM? 30 | 31 | This package uses WebAssembly for several key benefits: 32 | 33 | - **Performance**: Native-speed parsing, significantly faster than pure JavaScript 34 | - **Reliability**: Shared implementation with the official Rust parser means consistent behavior across platforms 35 | - **Maintainability**: Changes to the Cooklang spec are implemented once in Rust and automatically available here 36 | 37 | ## Migration from @cooklang/cooklang-ts 38 | 39 | If you're migrating from the previous TypeScript-native `@cooklang/cooklang-ts` package (v1.x), please see the [Migration Guide](MIGRATION.md). 40 | 41 | **Key differences:** 42 | - Different package name: `@cooklang/cooklang-ts` → `@cooklang/cooklang` 43 | - API changes: The WASM implementation has a different API surface 44 | - Version numbering: This package tracks the Rust core version (currently 0.17.x) 45 | 46 | ## API Reference 47 | 48 | ### Parser 49 | 50 | ```typescript 51 | import { Parser } from '@cooklang/cooklang'; 52 | 53 | const parser = new Parser(); 54 | const recipe = parser.parse(recipeText); 55 | ``` 56 | 57 | ### Recipe Structure 58 | 59 | The parsed recipe contains: 60 | 61 | ```typescript 62 | interface Recipe { 63 | sections: Section[]; 64 | metadata: Record; 65 | ingredients: Ingredient[]; 66 | cookware: Cookware[]; 67 | timers: Timer[]; 68 | } 69 | ``` 70 | 71 | ### Helper Functions 72 | 73 | ```typescript 74 | import { 75 | ingredient_should_be_listed, 76 | ingredient_display_name, 77 | cookware_should_be_listed, 78 | cookware_display_name, 79 | quantity_display, 80 | grouped_quantity_display, 81 | grouped_quantity_is_empty 82 | } from '@cooklang/cooklang'; 83 | ``` 84 | 85 | ### Value Extraction 86 | 87 | Utility functions for working with quantities: 88 | 89 | ```typescript 90 | import { getNumericValue, extractNumericRange } from '@cooklang/cooklang'; 91 | 92 | const value = ingredient.quantity?.value; 93 | const numeric = getNumericValue(value); // 2.5 94 | const range = extractNumericRange(value); // { start: 2, end: 3 } 95 | ``` 96 | 97 | ## Version Synchronization 98 | 99 | This package version tracks the Rust `cooklang-rs` version. For example: 100 | - npm `@cooklang/cooklang@0.17.2` = Rust `cooklang-rs@0.17.2` 101 | 102 | We use 0.x versioning to match the Rust core library. The parser is production-ready and actively maintained. We'll bump to 1.0 when the Rust core reaches 1.0. 103 | 104 | ## Browser Support 105 | 106 | This package works in: 107 | - Node.js 16+ 108 | - Modern browsers (Chrome, Firefox, Safari, Edge) 109 | - Bundlers (Webpack, Vite, Rollup, etc.) 110 | 111 | ## Contributing 112 | 113 | This package is part of the [cooklang-rs](https://github.com/cooklang/cooklang-rs) monorepo. Contributions are welcome! 114 | 115 | ## License 116 | 117 | MIT - see [LICENSE](../LICENSE) 118 | 119 | ## Links 120 | 121 | - [Cooklang Specification](https://cooklang.org/docs/spec/) 122 | - [Cooklang Website](https://cooklang.org) 123 | - [GitHub Repository](https://github.com/cooklang/cooklang-rs) 124 | - [Issue Tracker](https://github.com/cooklang/cooklang-rs/issues) 125 | -------------------------------------------------------------------------------- /src/analysis/mod.rs: -------------------------------------------------------------------------------- 1 | //! Analysis pass of the parser 2 | //! 3 | //! This is just if for some reason you want to split the parsing from the 4 | //! analysis. 5 | 6 | use crate::{ 7 | error::{CowStr, PassResult, SourceDiag}, 8 | Recipe, 9 | }; 10 | 11 | mod event_consumer; 12 | 13 | pub use event_consumer::parse_events; 14 | 15 | pub type AnalysisResult = PassResult; 16 | 17 | #[derive(PartialEq, Eq, Debug, Clone, Copy)] 18 | pub(crate) enum DefineMode { 19 | All, 20 | Components, 21 | Steps, 22 | Text, 23 | } 24 | 25 | #[derive(PartialEq, Eq, Debug, Clone, Copy)] 26 | pub(crate) enum DuplicateMode { 27 | New, 28 | Reference, 29 | } 30 | 31 | /// Extra configuration for the analysis of events 32 | #[derive(Default)] 33 | pub struct ParseOptions<'a> { 34 | /// Check recipe references for existence 35 | pub recipe_ref_check: Option>, 36 | /// Check metadata entries for validity 37 | /// 38 | /// Some checks are performed by default, but you can add your own here. 39 | /// The function receives the key, value and an [`CheckOptions`] where you 40 | /// can customize what happens to the key, including not running the default 41 | /// checks. 42 | pub metadata_validator: Option>, 43 | } 44 | 45 | /// Return type for check functions in [`ParseOptions`] 46 | /// 47 | /// `Error` and `Warning` contain hints to the user with why it 48 | /// failed and/or how to solve it. They should be ordered from most to least 49 | /// important. 50 | #[derive(Debug, Clone, PartialEq, Eq)] 51 | pub enum CheckResult { 52 | Ok, 53 | Warning(Vec), 54 | Error(Vec), 55 | } 56 | 57 | impl CheckResult { 58 | pub(crate) fn into_source_diag(self, message: F) -> Option 59 | where 60 | F: FnOnce() -> O, 61 | O: Into, 62 | { 63 | let (severity, hints) = match self { 64 | CheckResult::Ok => return None, 65 | CheckResult::Warning(hints) => (crate::error::Severity::Warning, hints), 66 | CheckResult::Error(hints) => (crate::error::Severity::Error, hints), 67 | }; 68 | let mut diag = SourceDiag::unlabeled(message(), severity, crate::error::Stage::Analysis); 69 | for hint in hints { 70 | diag.add_hint(hint); 71 | } 72 | Some(diag) 73 | } 74 | } 75 | 76 | /// Customize how a metadata entry should be treated 77 | /// 78 | /// By default the entry is included and the [`StdKey`](crate::metadata::StdKey) 79 | /// checks run. 80 | pub struct CheckOptions { 81 | include: bool, 82 | run_std_checks: bool, 83 | } 84 | 85 | impl Default for CheckOptions { 86 | fn default() -> Self { 87 | Self { 88 | include: true, 89 | run_std_checks: true, 90 | } 91 | } 92 | } 93 | 94 | impl CheckOptions { 95 | /// To include or not the metadata entry in the recipe 96 | /// 97 | /// If this is `false`, the entry will not be in the recipe. This will avoid 98 | /// keeping invalid values. 99 | pub fn include(&mut self, do_include: bool) { 100 | self.include = do_include; 101 | } 102 | 103 | /// To run or not the checks for [`StdKey`](crate::metadata::StdKey) 104 | /// 105 | /// Disable these checks if you want to change the semantics or structure of a 106 | /// [`StdKey`](crate::metadata::StdKey) and don't want the parser to issue 107 | /// warnings about it. 108 | /// 109 | /// If the key is **not** an [`StdKey`](crate::metadata::StdKey) this has no effect. 110 | pub fn run_std_checks(&mut self, do_check: bool) { 111 | self.run_std_checks = do_check; 112 | } 113 | } 114 | 115 | pub type RecipeRefCheck<'a> = Box CheckResult + 'a>; 116 | pub type MetadataValidator<'a> = 117 | Box CheckResult + 'a>; 118 | -------------------------------------------------------------------------------- /typescript/test/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import {it, expect, describe} from "vitest"; 2 | import { 3 | CooklangParser, 4 | getNumericValue, 5 | getQuantityValue, 6 | getQuantityUnit, 7 | getFlatIngredients, 8 | getFlatCookware, 9 | getFlatTimers 10 | } from "../index.js"; 11 | 12 | describe("Numeric Value Helpers", () => { 13 | it("extracts numeric values from quantities", () => { 14 | const input = ` 15 | Mix @flour{2%cups} with @water{500%ml}. 16 | `; 17 | 18 | const parser = new CooklangParser(); 19 | const [recipe] = parser.parse(input); 20 | 21 | // Test getQuantityValue 22 | const flourQty = getQuantityValue(recipe.ingredients[0].quantity); 23 | expect(flourQty).toEqual(2); 24 | 25 | const waterQty = getQuantityValue(recipe.ingredients[1].quantity); 26 | expect(waterQty).toEqual(500); 27 | }); 28 | 29 | it("extracts units from quantities", () => { 30 | const input = ` 31 | Mix @flour{2%cups} with @water{500%ml}. 32 | `; 33 | 34 | const parser = new CooklangParser(); 35 | const [recipe] = parser.parse(input); 36 | 37 | const flourUnit = getQuantityUnit(recipe.ingredients[0].quantity); 38 | expect(flourUnit).toEqual("cups"); 39 | 40 | const waterUnit = getQuantityUnit(recipe.ingredients[1].quantity); 41 | expect(waterUnit).toEqual("ml"); 42 | }); 43 | 44 | it("handles null quantities", () => { 45 | const input = ` 46 | Mix @flour with @water. 47 | `; 48 | 49 | const parser = new CooklangParser(); 50 | const [recipe] = parser.parse(input); 51 | 52 | const flourQty = getQuantityValue(recipe.ingredients[0].quantity); 53 | expect(flourQty).toBeNull(); 54 | 55 | const flourUnit = getQuantityUnit(recipe.ingredients[0].quantity); 56 | expect(flourUnit).toBeNull(); 57 | }); 58 | 59 | it("extracts start value from ranges", () => { 60 | const input = ` 61 | Add @sugar{1-2%cups}. 62 | `; 63 | 64 | const parser = new CooklangParser(); 65 | const [recipe] = parser.parse(input); 66 | 67 | const value = getNumericValue(recipe.ingredients[0].quantity?.value); 68 | expect(value).toEqual(1); // Should return start of range 69 | }); 70 | }); 71 | 72 | describe("Flat List Helpers", () => { 73 | it("creates flat ingredient list", () => { 74 | const input = ` 75 | Mix @flour{2%cups} with @water{500%ml} and @salt. 76 | `; 77 | 78 | const parser = new CooklangParser(); 79 | const [recipe] = parser.parse(input); 80 | 81 | const ingredients = getFlatIngredients(recipe); 82 | 83 | expect(ingredients).toHaveLength(3); 84 | 85 | expect(ingredients[0].name).toEqual("flour"); 86 | expect(ingredients[0].quantity).toEqual(2); 87 | expect(ingredients[0].unit).toEqual("cups"); 88 | expect(ingredients[0].displayText).toBeTruthy(); 89 | 90 | expect(ingredients[1].name).toEqual("water"); 91 | expect(ingredients[1].quantity).toEqual(500); 92 | expect(ingredients[1].unit).toEqual("ml"); 93 | 94 | expect(ingredients[2].name).toEqual("salt"); 95 | expect(ingredients[2].quantity).toBeNull(); 96 | expect(ingredients[2].unit).toBeNull(); 97 | }); 98 | 99 | it("creates flat cookware list", () => { 100 | const input = ` 101 | Use #pot and #pan{2}. 102 | `; 103 | 104 | const parser = new CooklangParser(); 105 | const [recipe] = parser.parse(input); 106 | 107 | const cookware = getFlatCookware(recipe); 108 | 109 | expect(cookware).toHaveLength(2); 110 | 111 | expect(cookware[0].name).toEqual("pot"); 112 | expect(cookware[0].quantity).toBeNull(); 113 | 114 | expect(cookware[1].name).toEqual("pan"); 115 | expect(cookware[1].quantity).toEqual(2); 116 | }); 117 | 118 | it("creates flat timer list", () => { 119 | const input = ` 120 | Cook for ~{10%minutes} then ~bake{30%minutes}. 121 | `; 122 | 123 | const parser = new CooklangParser(); 124 | const [recipe] = parser.parse(input); 125 | 126 | const timers = getFlatTimers(recipe); 127 | 128 | expect(timers).toHaveLength(2); 129 | 130 | expect(timers[0].name).toBeNull(); 131 | expect(timers[0].quantity).toEqual(10); 132 | expect(timers[0].unit).toEqual("minutes"); 133 | expect(timers[0].displayText).toBeTruthy(); 134 | 135 | expect(timers[1].name).toEqual("bake"); 136 | expect(timers[1].quantity).toEqual(30); 137 | expect(timers[1].unit).toEqual("minutes"); 138 | }); 139 | 140 | }); 141 | -------------------------------------------------------------------------------- /tests/frontmatter_tests.rs: -------------------------------------------------------------------------------- 1 | use indoc::indoc; 2 | 3 | #[test] 4 | fn test_invalid_yaml_frontmatter_becomes_warning() { 5 | let input = indoc! {r#" 6 | --- 7 | title: Test Recipe 8 | tags: [test 9 | invalid yaml here 10 | --- 11 | 12 | This is a test recipe with invalid YAML frontmatter. 13 | 14 | @eggs{2} and @butter{1%tbsp} 15 | "#}; 16 | 17 | let result = cooklang::parse(input); 18 | 19 | // Should not fail to parse 20 | assert!( 21 | result.output().is_some(), 22 | "Recipe should parse successfully despite invalid YAML" 23 | ); 24 | 25 | let recipe = result.output().unwrap(); 26 | let warnings = result.report(); 27 | 28 | // Should have warnings about invalid YAML 29 | assert!( 30 | !warnings.is_empty(), 31 | "Should have warnings about invalid YAML" 32 | ); 33 | 34 | // The frontmatter should be ignored, so metadata should be empty 35 | assert!( 36 | recipe.metadata.map.is_empty(), 37 | "Metadata should be empty when YAML is invalid" 38 | ); 39 | 40 | // Should still parse the recipe content 41 | assert_eq!( 42 | recipe.ingredients.len(), 43 | 2, 44 | "Should still parse ingredients" 45 | ); 46 | assert_eq!(recipe.ingredients[0].name, "eggs"); 47 | assert_eq!(recipe.ingredients[1].name, "butter"); 48 | } 49 | 50 | #[test] 51 | fn test_valid_yaml_frontmatter_still_works() { 52 | let input = indoc! {r#" 53 | --- 54 | title: Test Recipe 55 | tags: [test, recipe] 56 | prep_time: 10 min 57 | --- 58 | 59 | This is a test recipe with valid YAML frontmatter. 60 | 61 | @eggs{2} and @butter{1%tbsp} 62 | "#}; 63 | 64 | let result = cooklang::parse(input); 65 | 66 | // Should parse successfully 67 | assert!( 68 | result.output().is_some(), 69 | "Recipe should parse successfully" 70 | ); 71 | 72 | let recipe = result.output().unwrap(); 73 | 74 | // Metadata should be parsed 75 | assert!( 76 | !recipe.metadata.map.is_empty(), 77 | "Metadata should not be empty" 78 | ); 79 | assert_eq!( 80 | recipe.metadata.map.get("title").and_then(|v| v.as_str()), 81 | Some("Test Recipe"), 82 | "Title should be parsed correctly" 83 | ); 84 | 85 | // Should still parse the recipe content 86 | assert_eq!(recipe.ingredients.len(), 2, "Should parse ingredients"); 87 | assert_eq!(recipe.ingredients[0].name, "eggs"); 88 | assert_eq!(recipe.ingredients[1].name, "butter"); 89 | } 90 | 91 | #[test] 92 | fn test_invalid_yaml_with_colon_in_value() { 93 | let input = indoc! {r#" 94 | --- 95 | title: Recipe: with colon 96 | description: This has: many: colons 97 | tags: [unclosed 98 | --- 99 | 100 | @flour{2%cups} 101 | "#}; 102 | 103 | let result = cooklang::parse(input); 104 | 105 | // Should not fail to parse 106 | assert!( 107 | result.output().is_some(), 108 | "Recipe should parse successfully despite invalid YAML" 109 | ); 110 | 111 | let recipe = result.output().unwrap(); 112 | let warnings = result.report(); 113 | 114 | // Should have warnings 115 | assert!( 116 | !warnings.is_empty(), 117 | "Should have warnings about invalid YAML" 118 | ); 119 | 120 | // Metadata should be empty due to invalid YAML 121 | assert!( 122 | recipe.metadata.map.is_empty(), 123 | "Metadata should be empty when YAML is invalid" 124 | ); 125 | 126 | // Should still parse ingredients 127 | assert_eq!(recipe.ingredients.len(), 1); 128 | assert_eq!(recipe.ingredients[0].name, "flour"); 129 | } 130 | 131 | #[test] 132 | fn test_completely_malformed_yaml() { 133 | let input = indoc! {r#" 134 | --- 135 | { this is not valid yaml at all }}} 136 | : : : : 137 | --- 138 | 139 | Simple recipe with @salt and @pepper 140 | "#}; 141 | 142 | let result = cooklang::parse(input); 143 | 144 | // Should not fail to parse 145 | assert!( 146 | result.output().is_some(), 147 | "Recipe should parse successfully despite malformed YAML" 148 | ); 149 | 150 | let recipe = result.output().unwrap(); 151 | let warnings = result.report(); 152 | 153 | // Should have warnings 154 | assert!( 155 | !warnings.is_empty(), 156 | "Should have warnings about invalid YAML" 157 | ); 158 | 159 | // Should contain information about invalid YAML in warning 160 | let warning_str = format!("{warnings:?}"); 161 | assert!( 162 | warning_str.contains("Invalid YAML") || warning_str.contains("invalid YAML"), 163 | "Warning should mention invalid YAML" 164 | ); 165 | 166 | // Metadata should be empty 167 | assert!( 168 | recipe.metadata.map.is_empty(), 169 | "Metadata should be empty when YAML is invalid" 170 | ); 171 | 172 | // Should still parse the recipe 173 | assert_eq!(recipe.ingredients.len(), 2); 174 | assert_eq!(recipe.ingredients[0].name, "salt"); 175 | assert_eq!(recipe.ingredients[1].name, "pepper"); 176 | } 177 | -------------------------------------------------------------------------------- /typescript/PUBLISHING.md: -------------------------------------------------------------------------------- 1 | # Publishing Instructions 2 | 3 | This document explains how to publish the new `@cooklang/cooklang` package and deprecate the old `@cooklang/cooklang-ts` package. 4 | 5 | ## Overview 6 | 7 | - **New package**: `@cooklang/cooklang` @ v0.17.2 (this directory) 8 | - **Old package**: `@cooklang/cooklang-ts` @ v1.2.8 (in `../cooklang-ts/`) 9 | - **Strategy**: Publish new package, then deprecate old one 10 | 11 | ## Prerequisites 12 | 13 | 1. You must have npm publish permissions for the `@cooklang` scope 14 | 2. You must be logged in to npm: `npm whoami` 15 | 3. If not logged in: `npm login` 16 | 17 | ## Step 1: Publish the New Package 18 | 19 | From the `typescript/` directory in this repo: 20 | 21 | ```bash 22 | # Make sure you're in the right directory 23 | cd /Users/alexeydubovskoy/Cooklang/cooklang-rs/typescript 24 | 25 | # Build the package 26 | npm run build-wasm 27 | npm run build 28 | 29 | # Run tests to ensure everything works 30 | npm test 31 | 32 | # Verify the package contents 33 | npm pack --dry-run 34 | 35 | # Publish to npm (production release) 36 | npm publish --access public 37 | 38 | # Or for testing: publish to a different tag first 39 | npm publish --access public --tag next 40 | ``` 41 | 42 | ### Verify Publication 43 | 44 | After publishing, verify the package: 45 | 46 | ```bash 47 | # Check it's published 48 | npm view @cooklang/cooklang 49 | 50 | # Install it in a test project 51 | mkdir /tmp/test-cooklang 52 | cd /tmp/test-cooklang 53 | npm init -y 54 | npm install @cooklang/cooklang 55 | 56 | # Test it works 57 | node -e "const { Parser } = require('@cooklang/cooklang'); console.log(new Parser().parse('>> servings: 4'));" 58 | ``` 59 | 60 | ## Step 2: Publish Deprecation Release for Old Package 61 | 62 | From the `cooklang-ts/` directory: 63 | 64 | ```bash 65 | # Switch to the old package directory 66 | cd /Users/alexeydubovskoy/Cooklang/cooklang-ts 67 | 68 | # Build the package (includes deprecation warning) 69 | npm run build 70 | 71 | # Publish version 1.2.8 with deprecation 72 | npm publish 73 | ``` 74 | 75 | ## Step 3: Officially Deprecate the Old Package 76 | 77 | Use npm's deprecation feature: 78 | 79 | ```bash 80 | npm deprecate @cooklang/cooklang-ts "This package is deprecated. Please use @cooklang/cooklang instead. Migration guide: https://github.com/cooklang/cooklang-rs/blob/main/typescript/MIGRATION.md" 81 | ``` 82 | 83 | This will: 84 | - Show a warning when users run `npm install @cooklang/cooklang-ts` 85 | - Display the deprecation message in the npm registry 86 | - Not break existing installations 87 | 88 | ## Step 4: Update Documentation 89 | 90 | 1. **Update the main cooklang-rs README** if it references the old package 91 | 2. **Update any examples** in the repo to use `@cooklang/cooklang` 92 | 3. **Announce the change** in: 93 | - GitHub release notes 94 | - Cooklang website/docs 95 | - Discord/community channels 96 | - Twitter/social media 97 | 98 | ## Future Releases 99 | 100 | For future releases of `@cooklang/cooklang`: 101 | 102 | 1. Update version in `Cargo.toml` (root crate) 103 | 2. Update version in `typescript/package.json` to match 104 | 3. Build and test: `npm run build-wasm && npm run build && npm test` 105 | 4. Publish: `npm publish --access public` 106 | 107 | The version should always match the Rust crate version (e.g., `0.17.2`). 108 | 109 | ## Rollback Plan 110 | 111 | If something goes wrong: 112 | 113 | ### Unpublish (within 72 hours of publishing) 114 | 115 | ```bash 116 | npm unpublish @cooklang/cooklang@0.17.2 117 | ``` 118 | 119 | ⚠️ **Warning**: Unpublishing is permanent and can break projects. Only do this for critical issues. 120 | 121 | ### Deprecate the New Version 122 | 123 | ```bash 124 | npm deprecate @cooklang/cooklang@0.17.2 "This version has issues. Please use @cooklang/cooklang-ts@1.2.7 instead." 125 | ``` 126 | 127 | ### Publish a Fix 128 | 129 | ```bash 130 | # Fix the issue 131 | # Bump version to 0.17.3 132 | npm publish --access public 133 | ``` 134 | 135 | ## Troubleshooting 136 | 137 | ### "You do not have permission to publish" 138 | 139 | Make sure: 140 | 1. You're logged in: `npm whoami` 141 | 2. You have access to `@cooklang` scope 142 | 3. Contact the org owner to grant you access 143 | 144 | ### "Version already exists" 145 | 146 | You can't republish the same version. Bump the version in `package.json` and try again. 147 | 148 | ### "Package size exceeds limit" 149 | 150 | The WASM file is large (~2.8MB). This is expected. npm allows packages up to 10MB by default. 151 | 152 | ### Tests fail 153 | 154 | Don't publish if tests fail. Fix the issues first: 155 | 156 | ```bash 157 | npm test -- --reporter=verbose 158 | ``` 159 | 160 | ## Post-Publication Checklist 161 | 162 | - [ ] Verify package is visible on npmjs.com 163 | - [ ] Test installation in a fresh project 164 | - [ ] Update documentation/examples 165 | - [ ] Announce the release 166 | - [ ] Monitor for issues in the first 24-48 hours 167 | - [ ] Respond to user questions/issues promptly 168 | 169 | ## Support Timeline 170 | 171 | - **@cooklang/cooklang-ts v1.2.8**: Critical security fixes only 172 | - **@cooklang/cooklang v0.17.2+**: Full support, active development 173 | 174 | ## Questions? 175 | 176 | Open an issue on GitHub: https://github.com/cooklang/cooklang-rs/issues 177 | -------------------------------------------------------------------------------- /extensions.md: -------------------------------------------------------------------------------- 1 | # Cooklang syntax extensions 2 | 3 | ## Modifiers 4 | With the ingredient modifiers you can alter the behaviour of ingredients. There 5 | are 5 modifiers: 6 | - `@` **Recipe**. References another recipe by it's name. 7 | ```cooklang 8 | Add @@tomato sauce{200%ml}. 9 | ``` 10 | - `&` **Reference**. References another ingredient with the same name. If a 11 | quantity is given, the amount can be added. The ingredient must be defined 12 | before. If there are multiple definitions, use the last one. 13 | ```cooklang 14 | Add @flour{200%g} [...], then add more @&flour{300%g}. 15 | ``` 16 | - `-` **Hidden**. Hidden in the list, only appears inline. 17 | ```cooklang 18 | Add some @-salt. 19 | ``` 20 | - `?` **Optional**. Mark the ingredient as optional. 21 | ```cooklang 22 | Now you can add @?thyme. 23 | ``` 24 | - `+` **New**. Forces to create a new ingredient. This works with the 25 | [modes](#modes) extension. 26 | 27 | This also works (except recipe) for cookware. 28 | 29 | ## Intermediate preparations 30 | You can refer to intermediate preparations as ingredients. For example: 31 | ```cooklang 32 | Add @flour{200%g} and @water. Mix until combined. 33 | 34 | Let the @&(~1)dough{} rest for ~{1%hour}. 35 | ``` 36 | Here, `dough` is refering to whatever was prepared one step back. 37 | These ingredients will not appear in the list. 38 | 39 | There are more syntax variations: 40 | ```cooklang 41 | @&(~1)thing{} -- 1 step back 42 | @&(2)thing{} -- step number 2 43 | @&(=2)thing{} -- section number 2 44 | @&(=~2)thing{} -- 2 sections back 45 | ``` 46 | 47 | Only past steps from the current section can be referenced. It can only be 48 | combined with the optional (`?`) modifier. Text steps can't be referenced. In 49 | relative references, text steps are ignored. Enabling this extension 50 | automatically enables the [modifiers](#modifiers) extension. 51 | 52 | ## Component alias 53 | Add an alias to an ingredient to display a different name. 54 | 55 | ```cooklang 56 | @white wine|wine{} 57 | @@tomato sauce|sauce{} -- works with modifiers too 58 | ``` 59 | 60 | This can be useful with references. Here, the references will be displayed as 61 | `flour` even though the ingredient it's refering is `tipo zero flour`. 62 | 63 | ```cooklang 64 | Add the @tipo zero flour{} 65 | Add more @&tipo zero flour|flour{} 66 | ``` 67 | 68 | This also works for cookware. 69 | 70 | ## Advanced units 71 | Maybe confusing name. Tweaks a little bit the parsing and behaviour of units 72 | inside quantities. 73 | 74 | - When the value is a number or a range and the values does not start with a 75 | number, the unit separator (`%`) can be replaced with a space. 76 | ```cooklang 77 | @water{1 L} is the same as @water{1%L} 78 | ``` 79 | 80 | If disabled, `@water{1 L}` would parse as `1 L` being a text value. 81 | - Enables extra checks: 82 | - Checks that units between references are compatible, so they can be added. 83 | - Checks that timers have a time unit. 84 | 85 | ## Modes 86 | Add new special metadata keys that control some of the other extensions. The 87 | special keys are between square brackets. 88 | 89 | ```cooklang 90 | >> [special key]: value 91 | ``` 92 | 93 | - `[mode]` | `[define]` 94 | - `all` | `default`. This is the default mode, same as the original cooklang. 95 | - `ingredients` | `components`. In this mode only components can be defined, 96 | all regular text is omitted. Useful for writing an ingredient list manually 97 | at the beginning of the recipe if you want to do so. 98 | - `steps`. All the ingredients are references. To force a new ingredient, use 99 | the new (`+`) modifier. 100 | - `text`. All steps are [text blocks](#text-blocks) 101 | 102 | - `duplicate` 103 | - `new` | `default`. When a ingredient with the same name is found, create a 104 | new one. This is the original cooklang behaviour. 105 | - `reference` | `ref`. Ingredients have implicit references when needed. So 106 | ingredients with the same name will be references. To force a new ingredient, 107 | use the new (`+`) modifier. 108 | ```cooklang 109 | >> [duplicate]: ref 110 | @water{1} @water{2} 111 | -- is the same as 112 | >> [duplicate]: default 113 | @water{1} @&water{2} 114 | ``` 115 | 116 | ## Temperature 117 | Find temperatures in the text, without any markers. In the future this may be 118 | extended to any unit. 119 | 120 | For example, the temperature here will be parsed[^2] not as text, but as an inline 121 | quantity. 122 | ```cooklang 123 | Preheat the #oven to 180 ºC. 124 | ``` 125 | 126 | ## Range values 127 | Recipes are not always exact. This is a little improvement that should help 128 | comunicating that in some cases. 129 | 130 | ```cooklang 131 | @eggs{2-4} 132 | @tomato sauce{200-300%ml} -- works with units 133 | @water{1.5-2%l} -- with decimal numbers too 134 | @flour{100%g} ... @&flour{200-400%g} -- the total will be 300-500 g 135 | ``` 136 | 137 | ## Timer requires time 138 | Just an extra rule that makes timers like `~name` invalid. 139 | 140 | [^1]: This is work in progress in `cooklang` but supported here. 141 | 142 | [^2]: Currently this is done in the analysis pass. So in the AST there is no 143 | concept of inline quantities. 144 | 145 | ### Name with URL 146 | 147 | Example: `Mom's Cookbook ` -> name: `Mom's Cookbook` url: `https://moms-cookbook.url/` 148 | 149 | The interpretations of the key value will be: 150 | 151 | - `name ` -> as `name` & `url` 152 | - `name ` -> as `name` 153 | - `name` -> as `name` 154 | - `invalid url` -> as `name` 155 | - `` -> as `name` 156 | - `valid url` -> as `url` 157 | - `` -> as `url` 158 | -------------------------------------------------------------------------------- /src/parser/metadata.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::label, lexer::T}; 2 | 3 | use super::{error, warning, BlockParser, Event}; 4 | 5 | pub(crate) fn metadata_entry<'i>(block: &mut BlockParser<'_, 'i>) -> Option> { 6 | // Parse 7 | block.consume(T![meta])?; 8 | let key_pos = block.current_offset(); 9 | let key_tokens = block.until(|t| t == T![:]).or_else(|| { 10 | block.warn( 11 | warning!( 12 | "A metadata block is invalid and it will be a step", 13 | label!(block.span()), 14 | ) 15 | .hint("Missing separator `:`"), 16 | ); 17 | None 18 | })?; 19 | let key = block.text(key_pos, key_tokens); 20 | block.bump(T![:]); 21 | let value_pos = block.current_offset(); 22 | let value_tokens = block.consume_rest(); 23 | let value = block.text(value_pos, value_tokens); 24 | 25 | // Checks 26 | if key.is_text_empty() { 27 | block.error( 28 | error!( 29 | "Empty metadata key", 30 | label!(key.span(), "write the key here"), 31 | ) 32 | .hint("The key cannot be empty"), 33 | ); 34 | } else if value.is_text_empty() { 35 | block.warn( 36 | warning!( 37 | format!("Empty metadata value for key: {}", key.text_trimmed()), 38 | label!(value.span(), "write a value here"), 39 | ) 40 | .label(label!(key.span())), 41 | ); 42 | } 43 | 44 | Some(Event::Metadata { key, value }) 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use std::collections::VecDeque; 50 | 51 | use super::*; 52 | use crate::{ 53 | parser::{token_stream::tokens, BlockParser}, 54 | span::Span, 55 | Extensions, 56 | }; 57 | 58 | #[test] 59 | fn basic_metadata_entry() { 60 | let input = ">> key: value"; 61 | let tokens = tokens![meta.2, ws.1, word.3, :.1, ws.1, word.5]; 62 | let mut events = VecDeque::new(); 63 | let mut bp = BlockParser::new(&tokens, input, &mut events, Extensions::all()); 64 | let entry = metadata_entry(&mut bp).unwrap(); 65 | bp.finish(); 66 | let Event::Metadata { key, value } = entry else { 67 | panic!() 68 | }; 69 | assert_eq!(key.text(), " key"); 70 | assert_eq!(key.span(), Span::new(2, 6)); 71 | assert_eq!(value.text(), " value"); 72 | assert_eq!(value.span(), Span::new(7, 13)); 73 | assert!(events.is_empty()); 74 | } 75 | 76 | #[test] 77 | fn no_key_metadata_entry() { 78 | let input = ">>: value"; 79 | let tokens = tokens![meta.2, :.1, ws.1, word.5]; 80 | let mut events = VecDeque::new(); 81 | let mut bp = BlockParser::new(&tokens, input, &mut events, Extensions::all()); 82 | let entry = metadata_entry(&mut bp).unwrap(); 83 | bp.finish(); 84 | let Event::Metadata { key, value } = entry else { 85 | panic!() 86 | }; 87 | assert_eq!(key.text(), ""); 88 | assert_eq!(key.span(), Span::pos(2)); 89 | assert_eq!(value.text_trimmed(), "value"); 90 | assert_eq!(events.len(), 1); 91 | assert!(matches!(events[0], Event::Error(_))); 92 | } 93 | 94 | #[test] 95 | fn no_val_metadata_entry() { 96 | let input = ">> key:"; 97 | let tokens = tokens![meta.2, ws.1, word.3, :.1]; 98 | let mut events = VecDeque::new(); 99 | let mut bp = BlockParser::new(&tokens, input, &mut events, Extensions::all()); 100 | let entry = metadata_entry(&mut bp).unwrap(); 101 | bp.finish(); 102 | let Event::Metadata { key, value } = entry else { 103 | panic!() 104 | }; 105 | assert_eq!(key.text_trimmed(), "key"); 106 | assert_eq!(value.text(), ""); 107 | assert_eq!(value.span(), Span::pos(7)); 108 | assert_eq!(events.len(), 1); 109 | assert!(matches!(events[0], Event::Warning(_))); 110 | 111 | let input = ">> key: "; 112 | let tokens = tokens![meta.2, ws.1, word.3, :.1, ws.2]; 113 | let mut events = VecDeque::new(); 114 | let mut bp = BlockParser::new(&tokens, input, &mut events, Extensions::all()); 115 | let entry = metadata_entry(&mut bp).unwrap(); 116 | bp.finish(); 117 | let Event::Metadata { key, value } = entry else { 118 | panic!() 119 | }; 120 | assert_eq!(key.text_trimmed(), "key"); 121 | assert_eq!(value.text(), " "); 122 | assert_eq!(value.span(), Span::new(7, 9)); 123 | assert_eq!(events.len(), 1); 124 | assert!(matches!(events[0], Event::Warning(_))); 125 | } 126 | 127 | #[test] 128 | fn empty_metadata_entry() { 129 | let input = ">>:"; 130 | let tokens = tokens![meta.2, :.1]; 131 | let mut events = VecDeque::new(); 132 | let mut bp = BlockParser::new(&tokens, input, &mut events, Extensions::all()); 133 | let entry = metadata_entry(&mut bp).unwrap(); 134 | bp.finish(); 135 | let Event::Metadata { key, value } = entry else { 136 | panic!() 137 | }; 138 | assert!(key.text().is_empty()); 139 | assert_eq!(key.span(), Span::pos(2)); 140 | assert!(value.text().is_empty()); 141 | assert_eq!(value.span(), Span::pos(3)); 142 | assert_eq!(events.len(), 1); // no warning if error generated 143 | assert!(matches!(events[0], Event::Error(_))); 144 | 145 | let input = ">> "; 146 | let tokens = tokens![meta.2, ws.1]; 147 | let mut events = VecDeque::new(); 148 | let mut bp = BlockParser::new(&tokens, input, &mut events, Extensions::all()); 149 | assert!(metadata_entry(&mut bp).is_none()); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /tests/canonical.rs: -------------------------------------------------------------------------------- 1 | //! Cooklang canonical tests https://github.com/cooklang/spec/blob/main/tests/canonical.yaml 2 | 3 | use cooklang::{quantity::Value, Content, Converter, CooklangParser, Extensions, Item, Recipe}; 4 | use serde::Deserialize; 5 | 6 | #[derive(Deserialize, PartialEq, Debug)] 7 | struct TestCase { 8 | source: String, 9 | result: TestResult, 10 | } 11 | 12 | #[derive(Deserialize, PartialEq, Debug)] 13 | struct TestResult { 14 | steps: Vec, 15 | metadata: serde_yaml::Mapping, 16 | } 17 | 18 | #[derive(Deserialize, PartialEq, Debug)] 19 | #[serde(transparent)] 20 | struct TestStep(Vec); 21 | 22 | #[derive(Deserialize, PartialEq, Debug)] 23 | #[serde(tag = "type", rename_all = "camelCase")] 24 | enum TestStepItem { 25 | Text { 26 | value: String, 27 | }, 28 | Ingredient { 29 | name: String, 30 | quantity: TestValue, 31 | units: String, 32 | }, 33 | Cookware { 34 | name: String, 35 | quantity: TestValue, 36 | }, 37 | Timer { 38 | name: String, 39 | quantity: TestValue, 40 | units: String, 41 | }, 42 | } 43 | 44 | #[derive(Deserialize, PartialEq, Debug)] 45 | #[serde(untagged)] 46 | enum TestValue { 47 | Number(f64), 48 | Text(String), 49 | } 50 | 51 | mod canonical_cases; 52 | 53 | fn runner(input: TestCase) { 54 | let parser = CooklangParser::new(Extensions::empty(), Converter::empty()); 55 | let got = parser 56 | .parse(&input.source) 57 | .into_output() 58 | .expect("Failed to parse"); 59 | let got_result = TestResult::from_cooklang(got); 60 | assert_eq!(got_result, input.result); 61 | } 62 | 63 | impl TestResult { 64 | fn from_cooklang(value: Recipe) -> Self { 65 | assert!(value.sections.len() <= 1); 66 | let steps = if let Some(section) = value.sections.first().cloned() { 67 | assert!(section.name.is_none()); 68 | section 69 | .content 70 | .into_iter() 71 | .map(|v| TestStep::from_cooklang_step(v, &value)) 72 | .collect() 73 | } else { 74 | vec![] 75 | }; 76 | Self { 77 | steps, 78 | metadata: value.metadata.map, 79 | } 80 | } 81 | } 82 | 83 | impl TestStep { 84 | fn from_cooklang_step(value: Content, recipe: &Recipe) -> Self { 85 | let Content::Step(step) = value else { 86 | panic!("unexpected non step block") 87 | }; 88 | 89 | let items = join_text_items(&step.items); 90 | let items = items 91 | .into_iter() 92 | .map(|v| TestStepItem::from_cooklang_item(v, recipe)) 93 | .collect(); 94 | Self(items) 95 | } 96 | } 97 | 98 | impl TestStepItem { 99 | fn from_cooklang_item(value: Item, recipe: &Recipe) -> Self { 100 | match value { 101 | Item::Text { value } => Self::Text { value }, 102 | Item::Ingredient { index } => { 103 | let i = &recipe.ingredients[index]; 104 | assert!(i.relation.is_definition()); 105 | assert!(i.relation.referenced_from().is_empty()); 106 | assert!(i.modifiers().is_empty()); 107 | assert!(i.alias.is_none()); 108 | assert!(i.note.is_none()); 109 | let quantity = i 110 | .quantity 111 | .as_ref() 112 | .map(|q| TestValue::from_cooklang_value(q.value().clone())) 113 | .unwrap_or(TestValue::Text("some".into())); 114 | let units = i 115 | .quantity 116 | .as_ref() 117 | .and_then(|q| q.unit().map(|s| s.into())) 118 | .unwrap_or_default(); 119 | Self::Ingredient { 120 | name: i.name.clone(), 121 | quantity, 122 | units, 123 | } 124 | } 125 | Item::Cookware { index } => { 126 | let i = &recipe.cookware[index]; 127 | assert!(i.relation.is_definition()); 128 | assert!(i.relation.referenced_from().is_empty()); 129 | assert!(i.modifiers().is_empty()); 130 | assert!(i.alias.is_none()); 131 | assert!(i.note.is_none()); 132 | let quantity = i 133 | .quantity 134 | .as_ref() 135 | .map(|q| TestValue::from_cooklang_value(q.value().clone())) 136 | .unwrap_or(TestValue::Number(1.0)); 137 | Self::Cookware { 138 | name: i.name.clone(), 139 | quantity, 140 | } 141 | } 142 | Item::Timer { index } => { 143 | let i = &recipe.timers[index]; 144 | let quantity = i 145 | .quantity 146 | .as_ref() 147 | .map(|q| TestValue::from_cooklang_value(q.value().clone())) 148 | .unwrap_or(TestValue::Text("".into())); 149 | let units = i 150 | .quantity 151 | .as_ref() 152 | .and_then(|q| q.unit().map(|s| s.into())) 153 | .unwrap_or_default(); 154 | Self::Timer { 155 | name: i.name.clone().unwrap_or_default(), 156 | quantity, 157 | units, 158 | } 159 | } 160 | Item::InlineQuantity { index: _ } => panic!("Unexpected inline quantity"), 161 | } 162 | } 163 | } 164 | 165 | impl TestValue { 166 | fn from_cooklang_value(value: Value) -> Self { 167 | match value { 168 | Value::Number(num) => TestValue::Number(num.value()), 169 | Value::Range { .. } => panic!("unexpected range value"), 170 | Value::Text(value) => TestValue::Text(value), 171 | } 172 | } 173 | } 174 | 175 | // The parser may return text items splitted, but the tests don't account for that 176 | fn join_text_items(items: &[cooklang::model::Item]) -> Vec { 177 | let mut out = Vec::new(); 178 | for item in items { 179 | if let Item::Text { value: current } = item { 180 | if let Some(Item::Text { value: last }) = out.last_mut() { 181 | last.push_str(current); 182 | continue; 183 | } 184 | } 185 | out.push(item.clone()); 186 | } 187 | out 188 | } 189 | -------------------------------------------------------------------------------- /bindings/build-swift.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eo pipefail 4 | 5 | pushd `dirname $0` 6 | trap popd EXIT 7 | 8 | NAME="CooklangParser" 9 | VERSION=${1:-"1.0"} # first arg or "1.0" 10 | BUNDLE_IDENTIFIER="org.cooklang.$NAME" 11 | LIBRARY_NAME="libcooklang_bindings.a" 12 | FRAMEWORK_LIBRARY_NAME=${NAME}FFI 13 | FRAMEWORK_NAME="$FRAMEWORK_LIBRARY_NAME.framework" 14 | XC_FRAMEWORK_NAME="$FRAMEWORK_LIBRARY_NAME.xcframework" 15 | HEADER_NAME="${NAME}FFI.h" 16 | OUT_PATH="out" 17 | MIN_IOS_VERSION="16.0" 18 | WRAPPER_PATH="../swift/Sources/CooklangParser" 19 | 20 | AARCH64_APPLE_IOS_PATH="../target/aarch64-apple-ios/release" 21 | AARCH64_APPLE_IOS_SIM_PATH="../target/aarch64-apple-ios-sim/release" 22 | X86_64_APPLE_IOS_PATH="../target/x86_64-apple-ios/release" 23 | AARCH64_APPLE_DARWIN_PATH="../target/aarch64-apple-darwin/release" 24 | X86_64_APPLE_DARWIN_PATH="../target/x86_64-apple-darwin/release" 25 | 26 | targets=("aarch64-apple-ios" "aarch64-apple-ios-sim" "x86_64-apple-ios" "aarch64-apple-darwin" "x86_64-apple-darwin") 27 | 28 | # Build for all targets 29 | for target in "${targets[@]}"; do 30 | echo "Building for $target..." 31 | rustup target add $target 32 | cargo build --release --target $target 33 | done 34 | 35 | # Generate swift wrapper 36 | echo "Generating swift wrapper..." 37 | mkdir -p $OUT_PATH 38 | mkdir -p $WRAPPER_PATH 39 | CURRENT_ARCH=$(rustc --version --verbose | grep host | cut -f2 -d' ') 40 | 41 | cargo run --features="uniffi/cli" \ 42 | --bin uniffi-bindgen generate \ 43 | --config uniffi.toml \ 44 | --library ../target/$CURRENT_ARCH/release/$LIBRARY_NAME \ 45 | --language swift \ 46 | --out-dir $OUT_PATH 47 | 48 | # Merge libraries with lipo 49 | echo "Merging libraries with lipo..." 50 | lipo -create $AARCH64_APPLE_IOS_SIM_PATH/$LIBRARY_NAME \ 51 | $X86_64_APPLE_IOS_PATH/$LIBRARY_NAME \ 52 | -output $OUT_PATH/sim-$LIBRARY_NAME 53 | lipo -create $AARCH64_APPLE_DARWIN_PATH/$LIBRARY_NAME \ 54 | $X86_64_APPLE_DARWIN_PATH/$LIBRARY_NAME \ 55 | -output $OUT_PATH/macos-$LIBRARY_NAME 56 | 57 | # Create framework template 58 | rm -rf $OUT_PATH/$FRAMEWORK_NAME 59 | mkdir -p $OUT_PATH/$FRAMEWORK_NAME/Headers 60 | mkdir -p $OUT_PATH/$FRAMEWORK_NAME/Modules 61 | cp $OUT_PATH/$HEADER_NAME $OUT_PATH/$FRAMEWORK_NAME/Headers 62 | cat < $OUT_PATH/$FRAMEWORK_NAME/Modules/module.modulemap 63 | framework module $FRAMEWORK_LIBRARY_NAME { 64 | umbrella header "$HEADER_NAME" 65 | 66 | export * 67 | module * { export * } 68 | } 69 | EOT 70 | 71 | cat < $OUT_PATH/$FRAMEWORK_NAME/Info.plist 72 | 73 | 74 | 75 | 76 | CFBundleDevelopmentRegion 77 | en 78 | CFBundleExecutable 79 | $FRAMEWORK_LIBRARY_NAME 80 | CFBundleIdentifier 81 | $BUNDLE_IDENTIFIER 82 | CFBundleInfoDictionaryVersion 83 | 6.0 84 | CFBundleName 85 | $FRAMEWORK_LIBRARY_NAME 86 | CFBundlePackageType 87 | FMWK 88 | CFBundleShortVersionString 89 | 1.0 90 | CFBundleVersion 91 | $VERSION 92 | NSPrincipalClass 93 | 94 | MinimumOSVersion 95 | $MIN_IOS_VERSION 96 | 97 | 98 | EOT 99 | 100 | # Prepare frameworks for each platform 101 | rm -rf $OUT_PATH/frameworks 102 | mkdir -p $OUT_PATH/frameworks/sim 103 | mkdir -p $OUT_PATH/frameworks/ios 104 | mkdir -p $OUT_PATH/frameworks/macos 105 | cp -r $OUT_PATH/$FRAMEWORK_NAME $OUT_PATH/frameworks/sim/ 106 | cp -r $OUT_PATH/$FRAMEWORK_NAME $OUT_PATH/frameworks/ios/ 107 | cp -r $OUT_PATH/$FRAMEWORK_NAME $OUT_PATH/frameworks/macos/ 108 | mv $OUT_PATH/sim-$LIBRARY_NAME $OUT_PATH/frameworks/sim/$FRAMEWORK_NAME/$FRAMEWORK_LIBRARY_NAME 109 | mv $OUT_PATH/macos-$LIBRARY_NAME $OUT_PATH/frameworks/macos/$FRAMEWORK_NAME/$FRAMEWORK_LIBRARY_NAME 110 | cp $AARCH64_APPLE_IOS_PATH/$LIBRARY_NAME $OUT_PATH/frameworks/ios/$FRAMEWORK_NAME/$FRAMEWORK_LIBRARY_NAME 111 | 112 | # Convert macOS framework to versioned bundle structure 113 | echo "Converting macOS framework to versioned bundle structure..." 114 | MACOS_FRAMEWORK_PATH="$OUT_PATH/frameworks/macos/$FRAMEWORK_NAME" 115 | mkdir -p "$MACOS_FRAMEWORK_PATH/Versions/A/Headers" 116 | mkdir -p "$MACOS_FRAMEWORK_PATH/Versions/A/Modules" 117 | mkdir -p "$MACOS_FRAMEWORK_PATH/Versions/A/Resources" 118 | mv "$MACOS_FRAMEWORK_PATH/$FRAMEWORK_LIBRARY_NAME" "$MACOS_FRAMEWORK_PATH/Versions/A/$FRAMEWORK_LIBRARY_NAME" 119 | mv "$MACOS_FRAMEWORK_PATH/Headers/$HEADER_NAME" "$MACOS_FRAMEWORK_PATH/Versions/A/Headers/$HEADER_NAME" 120 | mv "$MACOS_FRAMEWORK_PATH/Modules/module.modulemap" "$MACOS_FRAMEWORK_PATH/Versions/A/Modules/module.modulemap" 121 | mv "$MACOS_FRAMEWORK_PATH/Info.plist" "$MACOS_FRAMEWORK_PATH/Versions/A/Resources/Info.plist" 122 | rmdir "$MACOS_FRAMEWORK_PATH/Headers" 123 | rmdir "$MACOS_FRAMEWORK_PATH/Modules" 124 | ln -s A "$MACOS_FRAMEWORK_PATH/Versions/Current" 125 | ln -s Versions/Current/$FRAMEWORK_LIBRARY_NAME "$MACOS_FRAMEWORK_PATH/$FRAMEWORK_LIBRARY_NAME" 126 | ln -s Versions/Current/Headers "$MACOS_FRAMEWORK_PATH/Headers" 127 | ln -s Versions/Current/Modules "$MACOS_FRAMEWORK_PATH/Modules" 128 | ln -s Versions/Current/Resources "$MACOS_FRAMEWORK_PATH/Resources" 129 | 130 | # Create xcframework 131 | echo "Creating xcframework..." 132 | rm -rf $OUT_PATH/$XC_FRAMEWORK_NAME 133 | xcodebuild -create-xcframework \ 134 | -framework $OUT_PATH/frameworks/sim/$FRAMEWORK_NAME \ 135 | -framework $OUT_PATH/frameworks/ios/$FRAMEWORK_NAME \ 136 | -framework $OUT_PATH/frameworks/macos/$FRAMEWORK_NAME \ 137 | -output $OUT_PATH/$XC_FRAMEWORK_NAME 138 | 139 | # Copy swift wrapper 140 | # Need some temporary workarounds to compile swift wrapper 141 | # https://github.com/rust-lang/cargo/issues/11953 142 | cat < $OUT_PATH/import.txt 143 | #if os(macOS) 144 | import SystemConfiguration 145 | #endif 146 | EOT 147 | cat $OUT_PATH/import.txt $OUT_PATH/$NAME.swift > $WRAPPER_PATH/$NAME.swift 148 | 149 | # Create zip archive 150 | echo "Creating zip archive..." 151 | ZIP_NAME="$XC_FRAMEWORK_NAME.zip" 152 | pushd $OUT_PATH 153 | rm -rf $ZIP_NAME 154 | zip -r $ZIP_NAME $XC_FRAMEWORK_NAME 155 | popd 156 | 157 | # Calculate SHA256 checksum 158 | echo "Calculating SHA256 checksum..." 159 | SHA256=$(shasum -a 256 $OUT_PATH/$ZIP_NAME | awk '{print $1}') 160 | echo "SHA256: $SHA256" 161 | 162 | # Update Package.swift with new version and checksum 163 | echo "Updating Package.swift..." 164 | PACKAGE_SWIFT_PATH="../Package.swift" 165 | sed -i '' "s|url: \"https://github.com/cooklang/cooklang-rs/releases/download/v[^/]*/CooklangParserFFI.xcframework.zip\"|url: \"https://github.com/cooklang/cooklang-rs/releases/download/v$VERSION/CooklangParserFFI.xcframework.zip\"|" $PACKAGE_SWIFT_PATH 166 | sed -i '' "s|checksum: \"[^\"]*\"|checksum: \"$SHA256\"|" $PACKAGE_SWIFT_PATH 167 | 168 | echo "Build complete! Archive ready at: $OUT_PATH/$ZIP_NAME" 169 | echo "Version: $VERSION" 170 | echo "SHA256: $SHA256" 171 | -------------------------------------------------------------------------------- /tests/general_tests.rs: -------------------------------------------------------------------------------- 1 | use cooklang::{Content, CooklangParser, Extensions, Item, Value}; 2 | use indoc::indoc; 3 | use test_case::test_case; 4 | 5 | #[test_case( 6 | indoc! {r#" 7 | first 8 | 9 | second 10 | "#} => vec![vec![Some(1), Some(2)]]; "basic" 11 | )] 12 | #[test_case( 13 | indoc! {r#" 14 | > text 15 | 16 | first 17 | 18 | second 19 | "#} => vec![vec![None, Some(1), Some(2)]]; "text start" 20 | )] 21 | #[test_case( 22 | indoc! {r#" 23 | first 24 | 25 | > text 26 | 27 | second 28 | "#} => vec![vec![Some(1), None, Some(2)]]; "text middle" 29 | )] 30 | #[test_case( 31 | indoc! {r#" 32 | first 33 | 34 | second 35 | == sect == 36 | first again 37 | "#} => vec![vec![Some(1), Some(2)], vec![Some(1)]]; "section reset" 38 | )] 39 | #[test_case( 40 | indoc! {r#" 41 | > text 42 | 43 | first 44 | 45 | second 46 | == sect == 47 | first again 48 | "#} => vec![vec![None, Some(1), Some(2)], vec![Some(1)]]; "complex 1" 49 | )] 50 | #[test_case( 51 | indoc! {r#" 52 | first 53 | 54 | > text 55 | 56 | second 57 | == sect == 58 | first again 59 | "#} => vec![vec![Some(1), None, Some(2)], vec![Some(1)]]; "complex 2" 60 | )] 61 | #[test_case( 62 | indoc! {r#" 63 | first 64 | 65 | second 66 | == sect == 67 | > text 68 | 69 | first again 70 | "#} => vec![vec![Some(1), Some(2)], vec![None, Some(1)]]; "complex 3" 71 | )] 72 | #[test_case( 73 | indoc! {r#" 74 | first 75 | 76 | second 77 | == sect == 78 | first again 79 | 80 | > text 81 | "#} => vec![vec![Some(1), Some(2)], vec![Some(1), None]]; "complex 4" 82 | )] 83 | #[test_case( 84 | indoc! {r#" 85 | > just text 86 | 87 | == sect == 88 | 89 | > text 90 | 91 | first again 92 | "#} => vec![vec![None], vec![None, Some(1)]]; "complex 5" 93 | )] 94 | fn step_number(src: &str) -> Vec>> { 95 | let parser = CooklangParser::new(Extensions::all(), Default::default()); 96 | let r = parser.parse(src).unwrap_output(); 97 | let numbers: Vec>> = r 98 | .sections 99 | .into_iter() 100 | .map(|sect| { 101 | sect.content 102 | .into_iter() 103 | .map(|c| match c { 104 | Content::Step(s) => Some(s.number), 105 | Content::Text(_) => None, 106 | }) 107 | .collect() 108 | }) 109 | .collect(); 110 | numbers 111 | } 112 | 113 | #[test] 114 | fn empty_not_empty() { 115 | let input = indoc! {r#" 116 | -- "empty" recipe 117 | 118 | -- with spaces 119 | 120 | -- that should actually be empty 121 | -- and not produce empty steps 122 | 123 | [- not even this -] 124 | "#}; 125 | 126 | // should be the same with multiline and without 127 | let parser = CooklangParser::new(Extensions::all(), Default::default()); 128 | let r = parser.parse(input).unwrap_output(); 129 | assert!(r.sections.is_empty()); 130 | 131 | let parser = CooklangParser::new(Extensions::all(), Default::default()); 132 | let r = parser.parse(input).unwrap_output(); 133 | assert!(r.sections.is_empty()); 134 | } 135 | 136 | #[test] 137 | fn empty_steps() { 138 | let input = indoc! {r#" 139 | == Section name to force the section == 140 | 141 | -- "empty" recipe 142 | 143 | -- with spaces 144 | 145 | -- that should actually be empty 146 | -- and not produce empty steps 147 | 148 | [- not even this -] 149 | "#}; 150 | 151 | // should be the same with multiline and without 152 | let parser = CooklangParser::new(Extensions::all(), Default::default()); 153 | let r = parser.parse(input).unwrap_output(); 154 | assert!(r.sections[0].content.is_empty()); 155 | 156 | let parser = CooklangParser::new(Extensions::all(), Default::default()); 157 | let r = parser.parse(input).unwrap_output(); 158 | assert!(r.sections[0].content.is_empty()); 159 | } 160 | 161 | #[test] 162 | fn whitespace_line_block_separator() { 163 | let input = indoc! {r#" 164 | a step 165 | 166 | another 167 | "#}; 168 | 169 | // should be the same with multiline and without 170 | let parser = CooklangParser::new(Extensions::all(), Default::default()); 171 | let r = parser.parse(input).unwrap_output(); 172 | assert_eq!(r.sections[0].content.len(), 2); 173 | } 174 | 175 | #[test] 176 | fn single_line_no_separator() { 177 | let input = indoc! {r#" 178 | a step 179 | >> meta: val 180 | another step 181 | = section 182 | "#}; 183 | let parser = CooklangParser::new(Extensions::all(), Default::default()); 184 | let r = parser.parse(input).unwrap_output(); 185 | assert_eq!(r.sections.len(), 2); 186 | assert_eq!(r.sections[0].content.len(), 2); 187 | assert_eq!(r.sections[1].content.len(), 0); 188 | assert_eq!(r.metadata.map.len(), 1); 189 | } 190 | 191 | #[test] 192 | fn multiple_temperatures() { 193 | let input = "text 2ºC more text 150 F end text"; 194 | let parser = CooklangParser::new(Extensions::all(), Default::default()); 195 | let r = parser.parse(input).unwrap_output(); 196 | assert_eq!(r.inline_quantities.len(), 2); 197 | assert_eq!(r.inline_quantities[0].value(), &Value::from(2.0)); 198 | assert_eq!(r.inline_quantities[0].unit(), Some("ºC")); 199 | assert_eq!(r.inline_quantities[1].value(), &Value::from(150.0)); 200 | assert_eq!(r.inline_quantities[1].unit(), Some("F")); 201 | let Content::Step(first_step) = &r.sections[0].content[0] else { 202 | panic!() 203 | }; 204 | assert_eq!( 205 | first_step.items, 206 | vec![ 207 | Item::Text { 208 | value: "text ".into() 209 | }, 210 | Item::InlineQuantity { index: 0 }, 211 | Item::Text { 212 | value: " more text ".into() 213 | }, 214 | Item::InlineQuantity { index: 1 }, 215 | Item::Text { 216 | value: " end text".into() 217 | } 218 | ] 219 | ); 220 | } 221 | 222 | #[test] 223 | fn no_steps_component_mode() { 224 | let input = indoc! {r#" 225 | >> [mode]: components 226 | @igr 227 | >> [mode]: steps 228 | = section 229 | step 230 | "#}; 231 | let r = cooklang::parse(input).unwrap_output(); 232 | assert_eq!(r.sections.len(), 1); 233 | assert_eq!(r.sections[0].name.as_deref(), Some("section")); 234 | assert!(matches!( 235 | r.sections[0].content.as_slice(), 236 | [Content::Step(_)] 237 | )); 238 | } 239 | 240 | #[test] 241 | fn text_steps_extension() { 242 | let input = "> text"; 243 | 244 | let r = CooklangParser::canonical().parse(input).unwrap_output(); 245 | assert!(matches!( 246 | r.sections[0].content.as_slice(), 247 | [Content::Text(_)] 248 | )); 249 | } 250 | 251 | #[test] 252 | fn timer_missing_unit_warning() { 253 | let input = "Cook for ~{30}"; 254 | 255 | let parser = CooklangParser::canonical(); 256 | let result = parser.parse(input); 257 | 258 | // Check that we get a warning, not an error 259 | let report = result.report(); 260 | 261 | // Verify we have exactly one warning 262 | assert_eq!(report.iter().count(), 1); 263 | 264 | // Check that it's a warning, not an error 265 | let diag = report.iter().next().unwrap(); 266 | assert_eq!(diag.severity, cooklang::error::Severity::Warning); 267 | 268 | // Verify the warning message 269 | assert!(diag 270 | .message 271 | .contains("Invalid timer quantity: missing unit")); 272 | 273 | // Should parse successfully (not error) 274 | let _r = result.unwrap_output(); 275 | } 276 | -------------------------------------------------------------------------------- /src/parser/model.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::Recover, located::Located, quantity::Value, span::Span, text::Text}; 2 | 3 | use bitflags::bitflags; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /// Lines that form a recipe. 7 | /// 8 | /// They may not be just 1 line in the file, as a single step can be parsed from 9 | /// multiple lines. 10 | #[derive(Debug, Serialize, PartialEq, Clone)] 11 | pub enum Block<'a> { 12 | /// Metadata entry 13 | Metadata { key: Text<'a>, value: Text<'a> }, 14 | /// Section divider 15 | /// 16 | /// In the ast, a section does not own steps, it just exists in between. 17 | Section { name: Option> }, 18 | /// Recipe step 19 | Step { 20 | /// Items that compose the step. 21 | /// 22 | /// This is in order, so to form the representation of the step just 23 | /// iterate over the items and process them in that order. 24 | items: Vec>, 25 | }, 26 | /// A paragraph of instructions 27 | TextBlock(Vec>), 28 | } 29 | 30 | /// An item of a [`Block::Step`]. 31 | #[derive(Debug, Serialize, PartialEq, Clone)] 32 | pub enum Item<'a> { 33 | /// Plain text 34 | Text(Text<'a>), 35 | Ingredient(Box>>), 36 | Cookware(Box>>), 37 | Timer(Box>>), 38 | } 39 | 40 | impl Item<'_> { 41 | /// Returns the location of the item in the original input 42 | pub fn span(&self) -> Span { 43 | match self { 44 | Item::Text(t) => t.span(), 45 | Item::Ingredient(c) => c.span(), 46 | Item::Cookware(c) => c.span(), 47 | Item::Timer(c) => c.span(), 48 | } 49 | } 50 | } 51 | 52 | /// Ingredient [`Item`] 53 | #[derive(Debug, Clone, Serialize, PartialEq)] 54 | pub struct Ingredient<'a> { 55 | /// Ingredient modifiers 56 | /// 57 | /// If there are no modifiers, this will be [`Modifiers::empty`] and the 58 | /// location of where the modifiers would be. 59 | pub modifiers: Located, 60 | /// Data for intermediate references 61 | /// 62 | /// If any of those modifiers is present, this will be. 63 | pub intermediate_data: Option>, 64 | pub name: Text<'a>, 65 | pub alias: Option>, 66 | pub quantity: Option>>, 67 | pub note: Option>, 68 | } 69 | 70 | /// Cookware [`Item`] 71 | #[derive(Debug, Clone, Serialize, PartialEq)] 72 | pub struct Cookware<'a> { 73 | /// Cookware modifiers 74 | /// 75 | /// If there are no modifiers, this will be [`Modifiers::empty`] and the 76 | /// location of where the modifiers would be. 77 | pub modifiers: Located, 78 | pub name: Text<'a>, 79 | pub alias: Option>, 80 | /// This it's just a [`QuantityValue`], because cookware cannot not have 81 | /// a unit. 82 | pub quantity: Option>>, 83 | pub note: Option>, 84 | } 85 | 86 | /// Timer [`Item`] 87 | /// 88 | /// At least one of the fields is guaranteed to be [`Some`]. 89 | #[derive(Debug, Clone, Serialize, PartialEq)] 90 | pub struct Timer<'a> { 91 | pub name: Option>, 92 | /// If the [`TIMER_REQUIRES_TIME`](crate::Extensions::TIMER_REQUIRES_TIME) 93 | /// extension is enabled, this is guaranteed to be [`Some`]. 94 | pub quantity: Option>>, 95 | } 96 | 97 | /// Quantity used in [items](Item) 98 | #[derive(Debug, Clone, Serialize, PartialEq)] 99 | pub struct Quantity<'a> { 100 | /// Value or values 101 | pub value: QuantityValue, 102 | /// Unit text 103 | /// 104 | /// It's just the text, no checks 105 | pub unit: Option>, 106 | } 107 | 108 | /// Quantity value(s) 109 | #[derive(Debug, Clone, PartialEq, Serialize)] 110 | pub struct QuantityValue { 111 | pub value: Located, 112 | pub scaling_lock: Option, 113 | } 114 | 115 | impl QuantityValue { 116 | /// Calculates the span of the value 117 | pub fn span(&self) -> Span { 118 | self.value.span() 119 | } 120 | } 121 | 122 | impl Recover for Text<'_> { 123 | fn recover() -> Self { 124 | Self::empty(0) 125 | } 126 | } 127 | 128 | impl Recover for Quantity<'_> { 129 | fn recover() -> Self { 130 | Self { 131 | value: Recover::recover(), 132 | unit: Recover::recover(), 133 | } 134 | } 135 | } 136 | 137 | impl Recover for QuantityValue { 138 | fn recover() -> Self { 139 | Self { 140 | value: Recover::recover(), 141 | scaling_lock: None, 142 | } 143 | } 144 | } 145 | 146 | impl Recover for Value { 147 | fn recover() -> Self { 148 | 1.0.into() 149 | } 150 | } 151 | 152 | bitflags! { 153 | /// Component modifiers 154 | /// 155 | /// Sadly, for now this can represent invalid combinations of modifiers. 156 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 157 | pub struct Modifiers: u16 { 158 | /// refers to a recipe with the same name 159 | const RECIPE = 1 << 0; 160 | /// references another igr with the same name, if amount given will sum 161 | const REF = 1 << 1; 162 | /// not shown in the ingredient list, only inline 163 | const HIDDEN = 1 << 2; 164 | /// mark as optional 165 | const OPT = 1 << 3; 166 | /// forces to create a new ingredient 167 | const NEW = 1 << 4; 168 | } 169 | } 170 | 171 | impl Modifiers { 172 | /// Returns true if the component should be displayed in a list 173 | pub fn should_be_listed(self) -> bool { 174 | !self.intersects(Modifiers::HIDDEN | Modifiers::REF) 175 | } 176 | 177 | pub fn is_hidden(&self) -> bool { 178 | self.contains(Modifiers::HIDDEN) 179 | } 180 | 181 | pub fn is_optional(&self) -> bool { 182 | self.contains(Modifiers::OPT) 183 | } 184 | 185 | pub fn is_recipe(&self) -> bool { 186 | self.contains(Modifiers::RECIPE) 187 | } 188 | 189 | pub fn is_reference(&self) -> bool { 190 | self.contains(Modifiers::REF) 191 | } 192 | } 193 | 194 | impl std::fmt::Display for Modifiers { 195 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 196 | std::fmt::Display::fmt(&self.0, f) 197 | } 198 | } 199 | 200 | /// Data for interemediate references 201 | /// 202 | /// This is not checked, and may point to inexistent or future steps/sections 203 | /// which is invalid. 204 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 205 | pub struct IntermediateData { 206 | /// The mode in which `val` works 207 | pub ref_mode: IntermediateRefMode, 208 | /// The target of the reference 209 | pub target_kind: IntermediateTargetKind, 210 | /// Value 211 | /// 212 | /// This means: 213 | /// 214 | /// | `ref_mode`/`target_kind` | [`Step`] | [`Section`] | 215 | /// |:-------------------------|:---------------------------------------|:--------------------------| 216 | /// | [`Number`] | Step number **in the current section** | Section number | 217 | /// | [`Relative`] | Number of non text steps back | Number of sections back | 218 | /// 219 | /// [`Step`]: IntermediateTargetKind::Step 220 | /// [`Section`]: IntermediateTargetKind::Section 221 | /// [`Number`]: IntermediateRefMode::Number 222 | /// [`Relative`]: IntermediateRefMode::Relative 223 | pub val: i16, 224 | } 225 | 226 | /// How to treat the value in [`IntermediateData`] 227 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 228 | pub enum IntermediateRefMode { 229 | /// Step or section number 230 | Number, 231 | /// Relative backwards 232 | /// 233 | /// When it is steps, is number of non text steps back. 234 | Relative, 235 | } 236 | 237 | /// What the target of [`IntermediateData`] is 238 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 239 | pub enum IntermediateTargetKind { 240 | /// A step in the current section 241 | Step, 242 | /// A section of the recipe 243 | Section, 244 | } 245 | -------------------------------------------------------------------------------- /src/scale.rs: -------------------------------------------------------------------------------- 1 | //! Support for recipe scaling 2 | 3 | use crate::{convert::Converter, quantity::Value, Quantity, Recipe}; 4 | use thiserror::Error; 5 | 6 | /// Error type for scaling operations 7 | #[derive(Debug, Error, serde::Serialize, serde::Deserialize)] 8 | #[cfg_attr(feature = "ts", derive(tsify::Tsify))] 9 | pub enum ScaleError { 10 | /// The recipe has no valid numeric servings value 11 | #[error("Cannot scale recipe: servings metadata is not a valid number")] 12 | InvalidServings, 13 | 14 | /// The recipe has no valid yield metadata 15 | #[error("Cannot scale recipe: yield metadata is missing or invalid")] 16 | InvalidYield, 17 | 18 | /// The units don't match between target and current yield 19 | #[error("Cannot scale recipe: unit mismatch (expected {expected}, got {got})")] 20 | UnitMismatch { expected: String, got: String }, 21 | } 22 | 23 | impl Recipe { 24 | /// Scale a recipe 25 | /// 26 | pub fn scale(&mut self, factor: f64, converter: &Converter) { 27 | let scale_quantity = |q: &mut Quantity| { 28 | if q.scalable { 29 | q.value.scale(factor); 30 | let _ = q.fit(converter); 31 | } 32 | }; 33 | 34 | // Update metadata with new servings (only if numeric) 35 | if let Some(current_servings) = self.metadata.servings() { 36 | if let Some(base) = current_servings.as_number() { 37 | let new_servings = (base as f64 * factor).round() as u32; 38 | if let Some(servings_value) = 39 | self.metadata.get_mut(crate::metadata::StdKey::Servings) 40 | { 41 | // Preserve the original type (string or number) 42 | match servings_value { 43 | serde_yaml::Value::String(_) => { 44 | *servings_value = serde_yaml::Value::String(new_servings.to_string()); 45 | } 46 | _ => { 47 | *servings_value = 48 | serde_yaml::Value::Number(serde_yaml::Number::from(new_servings)); 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | self.ingredients 56 | .iter_mut() 57 | .filter_map(|i| i.quantity.as_mut()) 58 | .for_each(scale_quantity); 59 | self.cookware 60 | .iter_mut() 61 | .filter_map(|i| i.quantity.as_mut()) 62 | .for_each(scale_quantity); 63 | self.timers 64 | .iter_mut() 65 | .filter_map(|i| i.quantity.as_mut()) 66 | .for_each(scale_quantity); 67 | } 68 | 69 | /// Scale to a specific number of servings 70 | /// 71 | /// - `target` is the wanted number of servings. 72 | /// 73 | /// Returns an error if the recipe doesn't have a valid numeric servings value. 74 | pub fn scale_to_servings( 75 | &mut self, 76 | target: u32, 77 | converter: &Converter, 78 | ) -> Result<(), ScaleError> { 79 | let current_servings = self 80 | .metadata 81 | .servings() 82 | .ok_or(ScaleError::InvalidServings)?; 83 | 84 | let base = current_servings 85 | .as_number() 86 | .ok_or(ScaleError::InvalidServings)?; 87 | 88 | let factor = target as f64 / base as f64; 89 | self.scale(factor, converter); 90 | 91 | // Update servings metadata to the target value 92 | if let Some(servings_value) = self.metadata.get_mut(crate::metadata::StdKey::Servings) { 93 | // Preserve the original type (string or number) 94 | match servings_value { 95 | serde_yaml::Value::String(_) => { 96 | *servings_value = serde_yaml::Value::String(target.to_string()); 97 | } 98 | _ => { 99 | *servings_value = serde_yaml::Value::Number(serde_yaml::Number::from(target)); 100 | } 101 | } 102 | } 103 | Ok(()) 104 | } 105 | 106 | /// Scale to a target value with optional unit 107 | /// 108 | /// This function intelligently chooses the appropriate scaling method: 109 | /// - If `target_unit` is `Some("servings")`, scales by servings 110 | /// - If `target_unit` is `Some(other_unit)`, scales by yield with that unit 111 | /// - If `target_unit` is `None`, applies direct scaling factor 112 | /// 113 | /// # Arguments 114 | /// - `target_value` - The target value (servings count, yield amount, or scaling factor) 115 | /// - `target_unit` - Optional unit ("servings" for servings-based, other for yield-based, None for direct factor) 116 | /// - `converter` - Unit converter for fitting quantities 117 | /// 118 | /// # Returns 119 | /// - `Ok(())` on successful scaling 120 | /// - `Err(ScaleError)` if scaling cannot be performed 121 | pub fn scale_to_target( 122 | &mut self, 123 | target_value: f64, 124 | target_unit: Option<&str>, 125 | converter: &Converter, 126 | ) -> Result<(), ScaleError> { 127 | match target_unit { 128 | Some("servings") | Some("serving") => { 129 | // Scale by servings - convert f64 to u32 130 | let servings = target_value.round() as u32; 131 | self.scale_to_servings(servings, converter) 132 | } 133 | Some(unit) => { 134 | // Scale by yield with the specified unit 135 | self.scale_to_yield(target_value, unit, converter) 136 | } 137 | None => { 138 | // Direct scaling factor 139 | self.scale(target_value, converter); 140 | Ok(()) 141 | } 142 | } 143 | } 144 | 145 | /// Scale to a specific yield amount with unit 146 | /// 147 | /// - `target_value` is the wanted yield amount 148 | /// - `target_unit` is the unit for the yield 149 | /// 150 | /// Returns an error if: 151 | /// - The recipe doesn't have yield metadata 152 | /// - The yield metadata is not in the correct format 153 | /// - The units don't match 154 | pub fn scale_to_yield( 155 | &mut self, 156 | target_value: f64, 157 | target_unit: &str, 158 | converter: &Converter, 159 | ) -> Result<(), ScaleError> { 160 | // Get current yield from metadata 161 | // TODO: use std keys 162 | let yield_value = self.metadata.get("yield").ok_or(ScaleError::InvalidYield)?; 163 | 164 | let yield_str = yield_value 165 | .as_str() 166 | .ok_or(ScaleError::InvalidYield)? 167 | .to_string(); // Clone to avoid borrowing issues 168 | 169 | // Parse yield value - only support "1000%g" format 170 | let parts: Vec<&str> = yield_str.split('%').collect(); 171 | if parts.len() != 2 { 172 | return Err(ScaleError::InvalidYield); 173 | } 174 | let current_value = parts[0] 175 | .parse::() 176 | .map_err(|_| ScaleError::InvalidYield)?; 177 | let current_unit = parts[1].to_string(); 178 | 179 | // Check that units match 180 | if current_unit != target_unit { 181 | return Err(ScaleError::UnitMismatch { 182 | expected: target_unit.to_string(), 183 | got: current_unit.to_string(), 184 | }); 185 | } 186 | 187 | let factor = target_value / current_value; 188 | self.scale(factor, converter); 189 | 190 | // Update yield metadata to the target value (always use % format) 191 | // TODO: use std keys 192 | if let Some(yield_meta) = self.metadata.get_mut("yield") { 193 | *yield_meta = serde_yaml::Value::String(format!("{target_value}%{target_unit}")); 194 | } 195 | 196 | Ok(()) 197 | } 198 | } 199 | 200 | impl Value { 201 | fn scale(&mut self, factor: f64) { 202 | match self { 203 | Value::Number(n) => { 204 | *n = (n.value() * factor).into(); 205 | } 206 | Value::Range { start, end } => { 207 | *start = (start.value() * factor).into(); 208 | *end = (end.value() * factor).into(); 209 | } 210 | Value::Text(_) => {} 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /tests/scale.rs: -------------------------------------------------------------------------------- 1 | use cooklang::{Converter, CooklangParser, Extensions}; 2 | 3 | #[test] 4 | fn test_scale_updates_servings_metadata() { 5 | let input = r#"--- 6 | servings: 4 7 | --- 8 | 9 | @flour{200%g} 10 | @eggs{2} 11 | Mix and bake."#; 12 | 13 | let parser = CooklangParser::new(Extensions::all(), Converter::default()); 14 | let mut recipe = parser.parse(input).unwrap_output(); 15 | 16 | // Check original servings 17 | assert_eq!( 18 | recipe.metadata.servings().and_then(|s| s.as_number()), 19 | Some(4) 20 | ); 21 | 22 | let orig_servings_value = recipe 23 | .metadata 24 | .get(cooklang::metadata::StdKey::Servings) 25 | .unwrap(); 26 | assert_eq!(orig_servings_value.as_u64(), Some(4)); 27 | 28 | // Scale to 8 servings (2x) 29 | recipe.scale_to_servings(8, &Converter::default()).unwrap(); 30 | 31 | // Check that servings in metadata were updated 32 | let scaled_servings_value = recipe 33 | .metadata 34 | .get(cooklang::metadata::StdKey::Servings) 35 | .unwrap(); 36 | assert_eq!(scaled_servings_value.as_u64(), Some(8)); 37 | } 38 | 39 | #[test] 40 | fn test_scale_by_factor_updates_servings_metadata() { 41 | let input = r#">> servings: 2 42 | 43 | @butter{100%g} 44 | @sugar{50%g}"#; 45 | 46 | let parser = CooklangParser::new(Extensions::all(), Converter::default()); 47 | let mut recipe = parser.parse(input).unwrap_output(); 48 | 49 | // Scale by factor of 3 50 | recipe.scale(3.0, &Converter::default()); 51 | 52 | // Check that servings in metadata were updated (2 * 3 = 6) 53 | let scaled_servings_value = recipe 54 | .metadata 55 | .get(cooklang::metadata::StdKey::Servings) 56 | .unwrap(); 57 | // Handle both string and number formats 58 | match scaled_servings_value { 59 | serde_yaml::Value::String(s) => assert_eq!(s, "6"), 60 | serde_yaml::Value::Number(n) => assert_eq!(n.as_u64(), Some(6)), 61 | _ => panic!("Unexpected servings value type"), 62 | } 63 | } 64 | 65 | #[test] 66 | fn test_scale_without_servings_metadata() { 67 | // Recipe without servings metadata 68 | let input = r#"@flour{200%g} 69 | @eggs{2}"#; 70 | 71 | let parser = CooklangParser::new(Extensions::all(), Converter::default()); 72 | let mut recipe = parser.parse(input).unwrap_output(); 73 | 74 | // Should not have servings 75 | assert_eq!(recipe.metadata.servings(), None); 76 | 77 | // Scale by factor of 2 78 | recipe.scale(2.0, &Converter::default()); 79 | 80 | // Should still not have servings in metadata 81 | assert!(recipe 82 | .metadata 83 | .get(cooklang::metadata::StdKey::Servings) 84 | .is_none()); 85 | } 86 | 87 | #[test] 88 | fn test_scale_with_fractional_servings() { 89 | let input = r#">> servings: 3 90 | 91 | @milk{300%ml}"#; 92 | 93 | let parser = CooklangParser::new(Extensions::all(), Converter::default()); 94 | let mut recipe = parser.parse(input).unwrap_output(); 95 | 96 | // Scale by factor that results in fractional servings (3 * 1.5 = 4.5, should round to 5) 97 | recipe.scale(1.5, &Converter::default()); 98 | 99 | let scaled_servings_value = recipe 100 | .metadata 101 | .get(cooklang::metadata::StdKey::Servings) 102 | .unwrap(); 103 | // Handle both string and number formats 104 | match scaled_servings_value { 105 | serde_yaml::Value::String(s) => assert_eq!(s, "5"), 106 | serde_yaml::Value::Number(n) => assert_eq!(n.as_u64(), Some(5)), 107 | _ => panic!("Unexpected servings value type"), 108 | } 109 | } 110 | 111 | #[test] 112 | fn test_scale_with_non_numeric_servings() { 113 | let input = r#">> servings: two 114 | 115 | @flour{200%g} 116 | @butter{100%g}"#; 117 | 118 | let parser = CooklangParser::new(Extensions::all(), Converter::default()); 119 | let mut recipe = parser.parse(input).unwrap_output(); 120 | 121 | // Should parse "two" as text servings 122 | let servings = recipe.metadata.servings(); 123 | assert!(servings.is_some()); 124 | assert_eq!(servings.as_ref().and_then(|s| s.as_number()), None); 125 | assert_eq!(servings.as_ref().and_then(|s| s.as_text()), Some("two")); 126 | 127 | // Scale by factor of 2 128 | recipe.scale(2.0, &Converter::default()); 129 | 130 | // Servings should remain unchanged as a string 131 | let servings_value = recipe 132 | .metadata 133 | .get(cooklang::metadata::StdKey::Servings) 134 | .unwrap(); 135 | assert_eq!(servings_value.as_str(), Some("two")); 136 | } 137 | 138 | #[test] 139 | fn test_scale_to_servings_with_parseable_string_servings() { 140 | let input = r#"--- 141 | servings: "serves 4 people" 142 | --- 143 | 144 | @rice{2%cups}"#; 145 | 146 | let parser = CooklangParser::new(Extensions::all(), Converter::default()); 147 | let mut recipe = parser.parse(input).unwrap_output(); 148 | 149 | // Should parse "serves 4 people" as Servings::Text("serves 4 people") since we don't do complex parsing 150 | let servings = recipe.metadata.servings(); 151 | assert!(servings.is_some()); 152 | assert_eq!(servings.as_ref().and_then(|s| s.as_number()), None); 153 | assert_eq!( 154 | servings.as_ref().and_then(|s| s.as_text()), 155 | Some("serves 4 people") 156 | ); 157 | 158 | // scale_to_servings should fail since "serves 4 people" is not parsed as a number 159 | let result = recipe.scale_to_servings(8, &Converter::default()); 160 | assert!(result.is_err()); 161 | 162 | // Recipe should remain unchanged 163 | let ingredient_quantity = &recipe.ingredients[0].quantity.as_ref().unwrap(); 164 | match ingredient_quantity.value() { 165 | cooklang::quantity::Value::Number(n) => { 166 | assert_eq!(n.value(), 2.0); // Original value unchanged 167 | } 168 | _ => panic!("Expected numeric value"), 169 | } 170 | } 171 | 172 | #[test] 173 | fn test_scale_to_servings_with_numeric_string() { 174 | let input = r#"--- 175 | servings: "4" 176 | --- 177 | 178 | @rice{2%cups}"#; 179 | 180 | let parser = CooklangParser::new(Extensions::all(), Converter::default()); 181 | let mut recipe = parser.parse(input).unwrap_output(); 182 | 183 | // Should parse "4" as Servings::Number(4) 184 | let servings = recipe.metadata.servings(); 185 | assert!(servings.is_some()); 186 | assert_eq!(servings.as_ref().and_then(|s| s.as_number()), Some(4)); 187 | 188 | // scale_to_servings should succeed 189 | let result = recipe.scale_to_servings(8, &Converter::default()); 190 | assert!(result.is_ok()); 191 | 192 | // Recipe should be scaled from 4 to 8 (factor of 2) 193 | let ingredient_quantity = &recipe.ingredients[0].quantity.as_ref().unwrap(); 194 | match ingredient_quantity.value() { 195 | cooklang::quantity::Value::Number(n) => { 196 | assert_eq!(n.value(), 4.0); // Original 2 * 2 197 | } 198 | _ => panic!("Expected numeric value"), 199 | } 200 | } 201 | 202 | #[test] 203 | fn test_scale_to_servings_with_non_numeric_servings() { 204 | let input = r#"--- 205 | servings: "varies" 206 | --- 207 | 208 | @rice{2%cups}"#; 209 | 210 | let parser = CooklangParser::new(Extensions::all(), Converter::default()); 211 | let mut recipe = parser.parse(input).unwrap_output(); 212 | 213 | // Should parse "varies" as Servings::Text("varies") 214 | let servings = recipe.metadata.servings(); 215 | assert!(servings.is_some()); 216 | assert_eq!(servings.as_ref().and_then(|s| s.as_number()), None); 217 | assert_eq!(servings.as_ref().and_then(|s| s.as_text()), Some("varies")); 218 | 219 | // scale_to_servings should fail when servings can't be parsed to number 220 | let result = recipe.scale_to_servings(8, &Converter::default()); 221 | 222 | // Check that it returns an error 223 | assert!(result.is_err()); 224 | match result.unwrap_err() { 225 | cooklang::scale::ScaleError::InvalidServings => { 226 | // Expected error 227 | } 228 | _ => panic!("Expected InvalidServings error, got a different error"), 229 | } 230 | 231 | // Recipe should remain unchanged 232 | let ingredient_quantity = &recipe.ingredients[0].quantity.as_ref().unwrap(); 233 | match ingredient_quantity.value() { 234 | cooklang::quantity::Value::Number(n) => { 235 | assert_eq!(n.value(), 2.0); // Original value 236 | } 237 | _ => panic!("Expected numeric value"), 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /typescript/MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migration Guide: @cooklang/cooklang-ts → @cooklang/cooklang 2 | 3 | This guide helps you migrate from the TypeScript-native `@cooklang/cooklang-ts` (v1.x) to the new WASM-powered `@cooklang/cooklang` (v0.17+). 4 | 5 | ## Why Migrate? 6 | 7 | The new WASM implementation offers: 8 | 9 | - **Better performance**: Native-speed parsing via WebAssembly 10 | - **Consistent behavior**: Shares implementation with the official Rust parser 11 | - **Easier maintenance**: Updates to the Cooklang spec are implemented once 12 | - **Active development**: This is the future of Cooklang parsing for JavaScript/TypeScript 13 | 14 | ## Quick Migration Checklist 15 | 16 | - [ ] Update package name in `package.json` 17 | - [ ] Update import statements 18 | - [ ] Change `Recipe` class instantiation to `Parser.parse()` 19 | - [ ] Update property access (e.g., `cookwares` → `cookware`) 20 | - [ ] Remove deprecated methods like `toCooklang()` and `getImageURL()` 21 | - [ ] Test your application thoroughly 22 | 23 | ## Installation 24 | 25 | ```bash 26 | # Uninstall old package 27 | npm uninstall @cooklang/cooklang-ts 28 | 29 | # Install new package 30 | npm install @cooklang/cooklang 31 | ``` 32 | 33 | ## API Changes 34 | 35 | ### Package Name 36 | 37 | ```typescript 38 | // Old 39 | import { Recipe, Parser } from '@cooklang/cooklang-ts'; 40 | 41 | // New 42 | import { Parser } from '@cooklang/cooklang'; 43 | ``` 44 | 45 | ### Recipe Parsing 46 | 47 | The biggest change: the `Recipe` class is removed. Use `Parser.parse()` directly. 48 | 49 | ```typescript 50 | // Old 51 | import { Recipe } from '@cooklang/cooklang-ts'; 52 | const recipe = new Recipe(source); 53 | 54 | // New 55 | import { Parser } from '@cooklang/cooklang'; 56 | const parser = new Parser(); 57 | const recipe = parser.parse(source); 58 | ``` 59 | 60 | ### Property Names 61 | 62 | Some property names have changed: 63 | 64 | ```typescript 65 | // Old 66 | recipe.cookwares // Array of cookware items 67 | recipe.steps // Array of steps 68 | 69 | // New 70 | recipe.cookware // Array of cookware items (singular) 71 | recipe.sections // Array of sections (each section contains content/steps) 72 | ``` 73 | 74 | ### Metadata Access 75 | 76 | Metadata access is the same: 77 | 78 | ```typescript 79 | // Both old and new 80 | recipe.metadata.servings 81 | recipe.metadata.source 82 | ``` 83 | 84 | ### Ingredients and Cookware 85 | 86 | The structure is different: 87 | 88 | ```typescript 89 | // Old 90 | recipe.ingredients // Flat array of all ingredients 91 | recipe.cookwares // Flat array of all cookware 92 | 93 | // New 94 | recipe.ingredients // Still available 95 | recipe.cookware // Singular name 96 | ``` 97 | 98 | ### Steps vs Sections 99 | 100 | The new parser uses "sections" instead of "steps": 101 | 102 | ```typescript 103 | // Old 104 | recipe.steps.forEach(step => { 105 | step.forEach(item => { 106 | if ('value' in item) { 107 | console.log(item.value); // Text content 108 | } else { 109 | console.log(item.type, item.name); // ingredient/cookware/timer 110 | } 111 | }); 112 | }); 113 | 114 | // New 115 | recipe.sections.forEach(section => { 116 | section.content.forEach(step => { 117 | step.items.forEach(item => { 118 | if (item.type === 'text') { 119 | console.log(item.value); 120 | } else if (item.type === 'ingredient') { 121 | console.log(item.name); 122 | } 123 | }); 124 | }); 125 | }); 126 | ``` 127 | 128 | ### Removed Features 129 | 130 | The following features from the old package are **not available** in the new WASM version: 131 | 132 | #### `toCooklang()` Method 133 | 134 | The `Recipe.toCooklang()` method that generated Cooklang source from a recipe object is removed. 135 | 136 | ```typescript 137 | // Old (NO LONGER AVAILABLE) 138 | const recipe = new Recipe(source); 139 | const cooklangString = recipe.toCooklang(); 140 | 141 | // Workaround: Keep the original source if you need it 142 | const originalSource = source; 143 | const recipe = parser.parse(source); 144 | // Use originalSource when needed 145 | ``` 146 | 147 | #### `getImageURL()` Function 148 | 149 | The helper function for constructing image URLs is removed. 150 | 151 | ```typescript 152 | // Old (NO LONGER AVAILABLE) 153 | import { getImageURL } from '@cooklang/cooklang-ts'; 154 | const url = getImageURL('Baked Potato', { extension: 'jpg', step: 2 }); 155 | 156 | // Workaround: Implement your own helper 157 | function getImageURL(name: string, options?: { step?: number; extension?: 'png' | 'jpg' }) { 158 | const ext = options?.extension || 'png'; 159 | const step = options?.step ? `.${options.step}` : ''; 160 | return `${name}${step}.${ext}`; 161 | } 162 | ``` 163 | 164 | #### Shopping List 165 | 166 | The shopping list feature is not yet exposed in the WASM bindings: 167 | 168 | ```typescript 169 | // Old (NO LONGER AVAILABLE) 170 | recipe.shoppingList 171 | 172 | // Workaround: Build your own shopping list from ingredients 173 | const shoppingList = recipe.ingredients.reduce((acc, ing) => { 174 | // Your custom logic here 175 | return acc; 176 | }, {}); 177 | ``` 178 | 179 | ## Value Extraction 180 | 181 | The new package provides helper functions for working with quantity values: 182 | 183 | ```typescript 184 | import { getNumericValue, extractNumericRange } from '@cooklang/cooklang'; 185 | 186 | const ingredient = recipe.ingredients[0]; 187 | const value = ingredient.quantity?.value; 188 | 189 | // Get a single numeric value (for ranges, returns start) 190 | const numeric = getNumericValue(value); // 2.5 191 | 192 | // Get range values 193 | const range = extractNumericRange(value); // { start: 2, end: 3 } 194 | ``` 195 | 196 | ## Complete Example 197 | 198 | ### Before (v1.x) 199 | 200 | ```typescript 201 | import { Recipe, Parser, getImageURL } from '@cooklang/cooklang-ts'; 202 | 203 | const source = ` 204 | >> servings: 4 205 | Add @salt and @pepper to taste. 206 | `; 207 | 208 | const recipe = new Recipe(source); 209 | 210 | console.log(recipe.metadata.servings); // "4" 211 | console.log(recipe.ingredients[0].name); // "salt" 212 | 213 | // Convert back to Cooklang 214 | const cooklangString = recipe.toCooklang(); 215 | 216 | // Get image URL 217 | const imageUrl = getImageURL('My Recipe', { step: 1 }); 218 | ``` 219 | 220 | ### After (v0.17+) 221 | 222 | ```typescript 223 | import { Parser } from '@cooklang/cooklang'; 224 | 225 | const source = ` 226 | >> servings: 4 227 | Add @salt and @pepper to taste. 228 | `; 229 | 230 | const parser = new Parser(); 231 | const recipe = parser.parse(source); 232 | 233 | console.log(recipe.metadata.servings); // "4" 234 | console.log(recipe.ingredients[0].name); // "salt" 235 | 236 | // toCooklang() not available - keep original source if needed 237 | const originalSource = source; 238 | 239 | // getImageURL() not available - use custom helper 240 | function getImageURL(name: string, options?: { step?: number; extension?: 'png' | 'jpg' }) { 241 | const ext = options?.extension || 'png'; 242 | const step = options?.step ? `.${options.step}` : ''; 243 | return `${name}${step}.${ext}`; 244 | } 245 | 246 | const imageUrl = getImageURL('My Recipe', { step: 1 }); 247 | ``` 248 | 249 | ## Type Definitions 250 | 251 | The new package includes full TypeScript type definitions. Import types as needed: 252 | 253 | ```typescript 254 | import type { 255 | Recipe, 256 | Ingredient, 257 | Cookware, 258 | Timer, 259 | Section, 260 | Content, 261 | Step, 262 | Item, 263 | Value, 264 | Quantity 265 | } from '@cooklang/cooklang'; 266 | ``` 267 | 268 | ## Testing Your Migration 269 | 270 | After migrating, ensure you: 271 | 272 | 1. **Run your test suite** - All tests should pass with the new API 273 | 2. **Check recipe parsing** - Verify recipes parse correctly 274 | 3. **Validate data access** - Ensure you can access all needed data from parsed recipes 275 | 4. **Test edge cases** - Complex recipes, special characters, etc. 276 | 277 | ## Getting Help 278 | 279 | If you encounter issues during migration: 280 | 281 | - Check the [README](./README.md) for API reference 282 | - Open an issue on [GitHub](https://github.com/cooklang/cooklang-rs/issues) 283 | - Review the [Cooklang specification](https://cooklang.org/docs/spec/) 284 | 285 | ## Version Numbering 286 | 287 | Don't be alarmed by the 0.x version number! This package: 288 | 289 | - Tracks the Rust core version (currently 0.17.x) 290 | - Is production-ready and actively maintained 291 | - Will bump to 1.0 when the Rust core does 292 | 293 | The version number reflects synchronization with the Rust parser, not stability. 294 | -------------------------------------------------------------------------------- /src/text.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fmt::Debug}; 2 | 3 | use serde::Serialize; 4 | 5 | use crate::{located::Located, span::Span}; 6 | 7 | /// Borrowed text with location information and the ability to skip fragments 8 | /// 9 | /// Comments are skipped, and an [`&str`] can't represent that by itself. 10 | /// 11 | /// This implemets [`PartialEq`] and it will return true if the text matches, it 12 | /// ignores the location. 13 | #[derive(Clone, Serialize)] 14 | pub struct Text<'a> { 15 | data: TextData<'a>, 16 | } 17 | 18 | #[derive(Clone, Serialize)] 19 | enum TextData<'a> { 20 | Empty { offset: usize }, 21 | Single { fragment: TextFragment<'a> }, 22 | Fragmented { fragments: Vec> }, 23 | } 24 | 25 | impl<'a> TextData<'a> { 26 | fn push(&mut self, fragment: TextFragment<'a>) { 27 | match self { 28 | TextData::Empty { .. } => *self = Self::Single { fragment }, 29 | TextData::Single { fragment: current } => { 30 | *self = Self::Fragmented { 31 | fragments: vec![*current, fragment], 32 | } 33 | } 34 | TextData::Fragmented { fragments } => fragments.push(fragment), 35 | } 36 | } 37 | 38 | fn as_slice(&self) -> &[TextFragment<'a>] { 39 | match self { 40 | TextData::Empty { .. } => &[], 41 | TextData::Single { fragment } => std::slice::from_ref(fragment), 42 | TextData::Fragmented { fragments } => fragments.as_slice(), 43 | } 44 | } 45 | 46 | fn span(&self) -> Span { 47 | match self { 48 | TextData::Empty { offset } => Span::pos(*offset), 49 | TextData::Single { fragment } => fragment.span(), 50 | TextData::Fragmented { fragments } => { 51 | let start = fragments.first().unwrap().span().start(); 52 | let end = fragments.last().unwrap().span().end(); 53 | Span::new(start, end) 54 | } 55 | } 56 | } 57 | } 58 | 59 | impl<'a> Text<'a> { 60 | pub(crate) fn empty(offset: usize) -> Self { 61 | Self { 62 | data: TextData::Empty { offset }, 63 | } 64 | } 65 | 66 | pub(crate) fn from_str(s: &'a str, offset: usize) -> Self { 67 | let mut t = Self::empty(offset); 68 | t.append_fragment(TextFragment::new(s, offset)); 69 | t 70 | } 71 | 72 | pub(crate) fn append_fragment(&mut self, fragment: TextFragment<'a>) { 73 | assert!(self.span().end() <= fragment.offset); 74 | if fragment.text.is_empty() { 75 | return; 76 | } 77 | self.data.push(fragment); 78 | } 79 | 80 | pub(crate) fn append_str(&mut self, s: &'a str, offset: usize) { 81 | self.append_fragment(TextFragment::new(s, offset)) 82 | } 83 | 84 | /// Get the span of the original input of the text 85 | /// 86 | /// If there are skipped fragments in between, these fragments will be included 87 | /// as [`Span`] is only a start an end. To be able to get multiple spans, see 88 | /// [`Self::fragments`]. 89 | pub fn span(&self) -> Span { 90 | self.data.span() 91 | } 92 | 93 | /// Get the text of all the fragments concatenated 94 | /// 95 | /// A soft break is always rendered as a ascii whitespace. 96 | pub fn text(&self) -> Cow<'a, str> { 97 | // Contiguous text fragments may be joined together without a copy. 98 | // but most Text instances will only be one fragment anyways 99 | 100 | let mut s = Cow::default(); 101 | for f in self.fragments() { 102 | let text = match f.kind { 103 | TextFragmentKind::Text => f.text, 104 | TextFragmentKind::SoftBreak => " ", 105 | }; 106 | s += text; 107 | } 108 | s 109 | } 110 | 111 | /// Get the text trimmed (start and end) 112 | pub fn text_outer_trimmed(&self) -> Cow<'a, str> { 113 | match self.text() { 114 | Cow::Borrowed(s) => Cow::Borrowed(s.trim()), 115 | Cow::Owned(s) => Cow::Owned(s.trim().to_owned()), 116 | } 117 | } 118 | 119 | /// Get the text trimmed from whitespaces before, after and in between words 120 | pub fn text_trimmed(&self) -> Cow<'a, str> { 121 | let t = self.text_outer_trimmed(); 122 | 123 | if !t.contains(" ") { 124 | return t; 125 | } 126 | 127 | let mut t = t.into_owned(); 128 | let mut prev = ' '; 129 | t.retain(|c| { 130 | let r = c != ' ' || prev != ' '; 131 | prev = c; 132 | r 133 | }); 134 | Cow::from(t) 135 | } 136 | 137 | /// Checks that the text is not empty or blank, i.e. whitespace does not count 138 | pub fn is_text_empty(&self) -> bool { 139 | self.fragments().iter().all(|f| f.text.trim().is_empty()) 140 | } 141 | 142 | /// Get all the [`TextFragment`]s that compose the text 143 | pub fn fragments(&self) -> &[TextFragment<'a>] { 144 | self.data.as_slice() 145 | } 146 | 147 | /// Convenience method to the the text in [`Located`] 148 | pub fn located_text_trimmed(&self) -> Located> { 149 | Located::new(self.text_trimmed(), self.span()) 150 | } 151 | 152 | /// Convenience method to the the text in [`Located`] and owned 153 | pub fn located_string_trimmed(&self) -> Located { 154 | self.located_text_trimmed().map(Cow::into_owned) 155 | } 156 | } 157 | 158 | impl std::fmt::Display for Text<'_> { 159 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 160 | f.write_str(&self.text()) 161 | } 162 | } 163 | 164 | impl std::fmt::Debug for Text<'_> { 165 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 166 | let fragments = self.fragments(); 167 | match fragments.len() { 168 | 0 => write!(f, " @ {:?}", self.span()), 169 | 1 => fragments[0].fmt(f), 170 | _ => f.debug_list().entries(fragments).finish(), 171 | } 172 | } 173 | } 174 | 175 | impl PartialEq for Text<'_> { 176 | fn eq(&self, other: &Self) -> bool { 177 | self.fragments() == other.fragments() 178 | } 179 | } 180 | 181 | impl From> for Span { 182 | fn from(value: Text<'_>) -> Self { 183 | value.span() 184 | } 185 | } 186 | 187 | /// Fragment that compose a [`Text`] 188 | /// 189 | /// This implemets [`PartialEq`] and it will return true if the text matches, it 190 | /// ignores the location. 191 | #[derive(Clone, Copy, Serialize)] 192 | pub struct TextFragment<'a> { 193 | text: &'a str, 194 | offset: usize, 195 | kind: TextFragmentKind, 196 | } 197 | 198 | #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] 199 | pub enum TextFragmentKind { 200 | Text, 201 | SoftBreak, 202 | } 203 | 204 | impl<'a> TextFragment<'a> { 205 | pub(crate) fn new(text: &'a str, offset: usize) -> Self { 206 | Self { 207 | text, 208 | offset, 209 | kind: TextFragmentKind::Text, 210 | } 211 | } 212 | 213 | pub(crate) fn soft_break(text: &'a str, offset: usize) -> Self { 214 | Self { 215 | text, 216 | offset, 217 | kind: TextFragmentKind::SoftBreak, 218 | } 219 | } 220 | 221 | /// Get the inner text 222 | pub fn text(&self) -> &str { 223 | self.text 224 | } 225 | 226 | /// Get the span of the original input of the fragment 227 | pub fn span(&self) -> Span { 228 | Span::new(self.start(), self.end()) 229 | } 230 | 231 | /// Start offset of the fragment 232 | pub fn start(&self) -> usize { 233 | self.offset 234 | } 235 | 236 | /// End offset (not included) of the fragment 237 | pub fn end(&self) -> usize { 238 | self.offset + self.text.len() 239 | } 240 | } 241 | 242 | impl Debug for TextFragment<'_> { 243 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 244 | match self.kind { 245 | TextFragmentKind::Text => write!(f, "{:?}", self.text), 246 | TextFragmentKind::SoftBreak => write!(f, "SoftBreak({:?})", self.text), 247 | }?; 248 | write!(f, " @ {:?}", self.span()) 249 | } 250 | } 251 | 252 | impl PartialEq for TextFragment<'_> { 253 | fn eq(&self, other: &Self) -> bool { 254 | self.text == other.text 255 | } 256 | } 257 | 258 | #[cfg(test)] 259 | mod tests { 260 | use super::Text; 261 | use test_case::test_case; 262 | 263 | #[test_case("a b c" => "a b c"; "no trim")] 264 | #[test_case(" a b c " => "a b c"; "outer trim")] 265 | #[test_case(" a b c " => "a b c"; "inner trim")] 266 | fn trim_whitespace(t: &str) -> String { 267 | let t = Text::from_str(t, 0); 268 | t.text_trimmed().into_owned() 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /playground/main.ts: -------------------------------------------------------------------------------- 1 | import {Parser, version, HTMLRenderer, CooklangParser} from "@cooklang/cooklang"; 2 | 3 | declare global { 4 | interface Window { 5 | ace: any; 6 | } 7 | } 8 | 9 | async function run(): Promise { 10 | const editor = window.ace.edit("editor", { 11 | wrap: true, 12 | printMargin: false, 13 | fontSize: 16, 14 | fontFamily: "Jetbrains Mono", 15 | placeholder: "Write your recipe here", 16 | }); 17 | 18 | const input = 19 | window.sessionStorage.getItem("input") ?? "Write your @recipe here!"; 20 | editor.setValue(input); 21 | 22 | const output = document.getElementById("output") as HTMLPreElement; 23 | const errors = document.getElementById("errors") as HTMLPreElement; 24 | const errorsDetails = document.getElementById( 25 | "errors-details" 26 | ) as HTMLDetailsElement; 27 | const parserSelect = document.getElementById( 28 | "parserSelect" 29 | ) as HTMLSelectElement; 30 | const jsonCheckbox = document.getElementById("json") as HTMLInputElement; 31 | const servings = document.getElementById("servings") as HTMLInputElement; 32 | const loadUnits = document.getElementById("loadUnits") as HTMLInputElement; 33 | const versionElement = document.getElementById("version") as HTMLPreElement; 34 | 35 | if (versionElement) { 36 | versionElement.textContent = version(); 37 | } 38 | 39 | const parser = new Parser(); 40 | const parser2 = new CooklangParser(); 41 | 42 | const search = new URLSearchParams(window.location.search); 43 | if (search.has("json")) { 44 | jsonCheckbox.checked = search.get("json") === "true"; 45 | } 46 | if (search.has("loadUnits")) { 47 | const load = search.get("loadUnits") === "true"; 48 | parser.load_units = load; 49 | parser2.units = load; 50 | } 51 | loadUnits.checked = parser.load_units; 52 | if (search.has("extensions")) { 53 | parser.extensions = Number(search.get("extensions")); 54 | parser2.extensions = Number(search.get("extensions")); 55 | } 56 | let mode = search.get("mode") || localStorage.getItem("mode"); 57 | if (mode !== null) { 58 | parserSelect.value = mode; 59 | setMode(mode); 60 | } 61 | 62 | function parse(): void { 63 | const input = editor.getValue(); 64 | window.sessionStorage.setItem("input", input); 65 | const test = parser.parse(input); 66 | console.log({test, s: JSON.stringify(test, null, 2)}); 67 | switch (parserSelect.value) { 68 | case "full": { 69 | const {value, error} = parser.parse_full(input, jsonCheckbox.checked); 70 | output.textContent = value; 71 | errors.innerHTML = error; 72 | break; 73 | } 74 | case "events": { 75 | const events = parser.parse_events(input); 76 | output.textContent = events; 77 | errors.innerHTML = ""; 78 | break; 79 | } 80 | case "ast": { 81 | const {value, error} = parser.parse_ast(input, jsonCheckbox.checked); 82 | output.textContent = value; 83 | errors.innerHTML = error; 84 | break; 85 | } 86 | case "render": { 87 | const {value, error} = parser.parse_render( 88 | input, 89 | servings.value.length === 0 ? null : servings.valueAsNumber 90 | ); 91 | output.innerHTML = value; 92 | errors.innerHTML = error; 93 | break; 94 | } 95 | case "stdmeta": { 96 | const {value, error} = parser.std_metadata(input); 97 | output.innerHTML = value; 98 | errors.innerHTML = error; 99 | break; 100 | } 101 | case "render2": { 102 | const [recipe, report] = parser2.parse(input, servings.value.length === 0 ? null : servings.valueAsNumber); 103 | const renderer = new HTMLRenderer(); 104 | output.innerHTML = renderer.render(recipe); 105 | errors.innerHTML = report; 106 | break; 107 | } 108 | } 109 | errorsDetails.open = errors.childElementCount !== 0; 110 | } 111 | 112 | editor.on("change", debounce(parse, 100)); 113 | parserSelect.addEventListener("change", (ev) => 114 | setMode((ev.target as HTMLSelectElement).value) 115 | ); 116 | jsonCheckbox.addEventListener("change", (ev) => { 117 | const params = new URLSearchParams(window.location.search); 118 | const target = ev.target as HTMLInputElement; 119 | if (target.checked) { 120 | params.set("json", "true"); 121 | } else { 122 | params.delete("json"); 123 | } 124 | window.history.replaceState( 125 | null, 126 | "", 127 | window.location.pathname + "?" + params.toString() 128 | ); 129 | parse(); 130 | }); 131 | 132 | loadUnits.addEventListener("change", (ev) => { 133 | const params = new URLSearchParams(window.location.search); 134 | const target = ev.target as HTMLInputElement; 135 | parser.load_units = !!target.checked; 136 | parser2.units = !!target.checked; 137 | if (target.checked) { 138 | params.delete("loadUnits"); 139 | } else { 140 | params.set("loadUnits", "false"); 141 | } 142 | window.history.replaceState( 143 | null, 144 | "", 145 | window.location.pathname + "?" + params.toString() 146 | ); 147 | parse(); 148 | }); 149 | 150 | servings.addEventListener("change", () => parse()); 151 | 152 | const extensionsContainer = document.getElementById( 153 | "extensions-container" 154 | ) as HTMLDivElement; 155 | 156 | const extensions: [string, number][] = [ 157 | ["COMPONENT_MODIFIERS", 1 << 1], 158 | ["COMPONENT_ALIAS", 1 << 3], 159 | ["ADVANCED_UNITS", 1 << 5], 160 | ["MODES", 1 << 6], 161 | ["INLINE_QUANTITIES", 1 << 7], 162 | ["RANGE_VALUES", 1 << 9], 163 | ["TIMER_REQUIRES_TIME", 1 << 10], 164 | ["INTERMEDIATE_PREPARATIONS", (1 << 11) | (1 << 1)], 165 | ]; 166 | 167 | extensions.forEach(([e, bits]) => { 168 | const elem = document.createElement("input"); 169 | elem.setAttribute("type", "checkbox"); 170 | elem.setAttribute("id", e); 171 | elem.setAttribute("data-ext-bits", bits.toString()); 172 | elem.checked = (parser.extensions & bits) === bits; 173 | const label = document.createElement("label"); 174 | label.setAttribute("for", e); 175 | label.textContent = e; 176 | const container = document.createElement("div"); 177 | container.appendChild(elem); 178 | container.appendChild(label); 179 | extensionsContainer.appendChild(container); 180 | 181 | elem.addEventListener("change", updateExtensions); 182 | }); 183 | 184 | function updateExtensions(): void { 185 | let e = 0; 186 | document.querySelectorAll("[data-ext-bits]:checked").forEach((elem) => { 187 | const bits = Number((elem as HTMLElement).getAttribute("data-ext-bits")); 188 | e |= bits; 189 | }); 190 | console.log(e); 191 | parser.extensions = e; 192 | parser2.extensions = e; 193 | 194 | const params = new URLSearchParams(window.location.search); 195 | params.set("extensions", e.toString()); 196 | window.history.replaceState( 197 | null, 198 | "", 199 | window.location.pathname + "?" + params.toString() 200 | ); 201 | parse(); 202 | } 203 | 204 | function setMode(mode: string): void { 205 | const params = new URLSearchParams(window.location.search); 206 | params.set("mode", mode); 207 | window.history.replaceState( 208 | null, 209 | "", 210 | window.location.pathname + "?" + params.toString() 211 | ); 212 | const jsonContainer = document.getElementById( 213 | "jsoncontainer" 214 | ) as HTMLDivElement; 215 | const servingsContainer = document.getElementById( 216 | "servingscontainer" 217 | ) as HTMLDivElement; 218 | jsonContainer.hidden = mode === "render" || mode === "render2" || mode === "events"; 219 | servingsContainer.hidden = mode !== "render" && mode !== "render2"; 220 | localStorage.setItem("mode", mode); 221 | parse(); 222 | } 223 | 224 | editor.focus(); 225 | parse(); 226 | } 227 | 228 | function debounce(fn: () => void, delay: number): () => void { 229 | let timer: number | null = null; 230 | let first = true; 231 | return () => { 232 | if (first) { 233 | fn(); 234 | first = false; 235 | } else { 236 | if (timer !== null) { 237 | clearTimeout(timer); 238 | } 239 | timer = setTimeout(fn, delay); 240 | } 241 | }; 242 | } 243 | 244 | run(); 245 | -------------------------------------------------------------------------------- /src/parser/block_parser.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use super::{token_stream::Token, tokens_span, Event}; 4 | use crate::{ 5 | error::SourceDiag, 6 | lexer::{TokenKind, T}, 7 | text::{Text, TextFragment}, 8 | Extensions, Span, 9 | }; 10 | 11 | macro_rules! debug_assert_adjacent { 12 | ($s:expr) => { 13 | debug_assert!( 14 | $s.windows(2).all(|w| w[0].span.end() == w[1].span.start()), 15 | "tokens are not adjacent" 16 | ) 17 | }; 18 | } 19 | 20 | pub(crate) struct BlockParser<'t, 'i> { 21 | tokens: &'t [Token], 22 | pub(crate) current: usize, 23 | pub(crate) input: &'i str, 24 | pub(crate) extensions: Extensions, 25 | pub(crate) events: &'t mut VecDeque>, 26 | } 27 | 28 | impl<'t, 'i> BlockParser<'t, 'i> { 29 | /// Create it from separate parts. 30 | /// - tokens must be adjacent (checked in debug) 31 | /// - slices's tokens's span must refer to the input (checked in debug) 32 | /// - input is the whole input str given to the lexer 33 | pub(crate) fn new( 34 | tokens: &'t [Token], 35 | input: &'i str, 36 | events: &'t mut VecDeque>, 37 | extensions: Extensions, 38 | ) -> Self { 39 | assert!(!tokens.is_empty()); 40 | debug_assert!( 41 | tokens.first().unwrap().span.start() < input.len() 42 | && tokens.last().unwrap().span.end() <= input.len(), 43 | "tokens out of input bounds" 44 | ); 45 | debug_assert_adjacent!(tokens); 46 | Self { 47 | tokens, 48 | current: 0, 49 | input, 50 | extensions, 51 | events, 52 | } 53 | } 54 | 55 | fn base_offset(&self) -> usize { 56 | self.tokens.first().unwrap().span.start() 57 | } 58 | 59 | pub(crate) fn event(&mut self, ev: Event<'i>) { 60 | self.events.push_back(ev); 61 | } 62 | 63 | /// Finish parsing the line assertions 64 | /// 65 | /// Panics if any token is left. 66 | pub(crate) fn finish(self) { 67 | assert_eq!( 68 | self.current, 69 | self.tokens.len(), 70 | "Block tokens not parsed. this is a bug" 71 | ); 72 | } 73 | 74 | pub(crate) fn extension(&self, ext: Extensions) -> bool { 75 | self.extensions.contains(ext) 76 | } 77 | 78 | /// Runs a function that can fail to parse the input. 79 | /// 80 | /// If the function succeeds, is just as it was called withtout recover. 81 | /// If the function fails, any token eaten by it will be restored. 82 | /// 83 | /// Note that any other state modification such as adding errors to the 84 | /// context will not be rolled back. 85 | pub(crate) fn with_recover(&mut self, f: F) -> Option 86 | where 87 | F: FnOnce(&mut Self) -> Option, 88 | { 89 | let old_current = self.current; 90 | let r = f(self); 91 | if r.is_none() { 92 | self.current = old_current; 93 | } 94 | r 95 | } 96 | 97 | /// Returns the slice of tokens consumed inside the given function 98 | pub(crate) fn capture_slice(&mut self, f: F) -> &'t [Token] 99 | where 100 | F: FnOnce(&mut Self), 101 | { 102 | let start = self.current; 103 | f(self); 104 | let end = self.current; 105 | &self.tokens[start..end] 106 | } 107 | 108 | /// Gets a token's matching str from the input 109 | pub(crate) fn token_str(&self, token: Token) -> &'i str { 110 | &self.input[token.span.range()] 111 | } 112 | 113 | pub(crate) fn slice_str(&self, s: &[Token]) -> &'i str { 114 | debug_assert_adjacent!(s); 115 | if s.is_empty() { 116 | return ""; 117 | } 118 | let start = s.first().unwrap().span.start(); 119 | let end = s.last().unwrap().span.end(); 120 | &self.input[start..end] 121 | } 122 | 123 | pub(crate) fn span(&self) -> Span { 124 | tokens_span(self.tokens) 125 | } 126 | 127 | pub(crate) fn text(&self, offset: usize, tokens: &[Token]) -> Text<'i> { 128 | debug_assert_adjacent!(tokens); 129 | 130 | let mut t = Text::empty(offset); 131 | if tokens.is_empty() { 132 | return t; 133 | } 134 | let mut start = tokens[0].span.start(); 135 | let mut end = start; 136 | assert_eq!(offset, start, "Offset of {:?} must be {offset}", tokens[0]); 137 | 138 | for token in tokens { 139 | match token.kind { 140 | T![newline] => { 141 | t.append_str(&self.input[start..end], start); 142 | t.append_fragment(TextFragment::soft_break( 143 | &self.input[token.span.range()], 144 | token.span.start(), 145 | )); 146 | start = token.span.end(); 147 | end = start; 148 | } 149 | T![line comment] | T![block comment] => { 150 | t.append_str(&self.input[start..end], start); 151 | start = token.span.end(); 152 | end = start; 153 | } 154 | T![escaped] => { 155 | t.append_str(&self.input[start..end], start); 156 | debug_assert_eq!(token.len(), 2, "unexpected escaped token length"); 157 | start = token.span.start() + 1; // skip "\" 158 | end = token.span.end() 159 | } 160 | _ => end = token.span.end(), 161 | } 162 | } 163 | t.append_str(&self.input[start..end], start); 164 | t 165 | } 166 | 167 | /// Returns the current offset from the start of input 168 | pub(crate) fn current_offset(&self) -> usize { 169 | self.parsed() 170 | .last() 171 | .map(|t| t.span.end()) 172 | .unwrap_or(self.base_offset()) 173 | } 174 | 175 | pub(crate) fn tokens(&self) -> &'t [Token] { 176 | self.tokens 177 | } 178 | 179 | pub(crate) fn parsed(&self) -> &'t [Token] { 180 | self.tokens.split_at(self.current).0 181 | } 182 | 183 | /// Returns the not parsed tokens 184 | pub(crate) fn rest(&self) -> &'t [Token] { 185 | self.tokens.split_at(self.current).1 186 | } 187 | 188 | pub(crate) fn consume_rest(&mut self) -> &'t [Token] { 189 | let r = self.rest(); 190 | self.current += r.len(); 191 | r 192 | } 193 | 194 | /// Peeks the next token without consuming it. 195 | pub(crate) fn peek(&self) -> TokenKind { 196 | self.tokens 197 | .get(self.current) 198 | .map(|token| token.kind) 199 | .unwrap_or(TokenKind::Eof) 200 | } 201 | 202 | /// Checks the next token without consuming it. 203 | pub(crate) fn at(&self, kind: TokenKind) -> bool { 204 | self.peek() == kind 205 | } 206 | 207 | /// Advance to the next token. 208 | #[must_use] 209 | pub(crate) fn next_token(&mut self) -> Option { 210 | if let Some(token) = self.tokens.get(self.current) { 211 | self.current += 1; 212 | Some(*token) 213 | } else { 214 | None 215 | } 216 | } 217 | 218 | /// Same as [`Self::next_token`] but panics if there are no more tokens. 219 | pub(crate) fn bump_any(&mut self) -> Token { 220 | self.next_token() 221 | .expect("Expected token, but there was none") 222 | } 223 | 224 | /// Call [`Self::next_token`] but panics if the next token is not `expected`. 225 | pub(crate) fn bump(&mut self, expected: TokenKind) -> Token { 226 | let token = self.bump_any(); 227 | assert_eq!( 228 | token.kind, expected, 229 | "Expected '{expected:?}', but got '{:?}'", 230 | token.kind 231 | ); 232 | token 233 | } 234 | 235 | /// Takes until condition reached, if never reached, return none 236 | pub(crate) fn until(&mut self, f: impl Fn(TokenKind) -> bool) -> Option<&'t [Token]> { 237 | let rest = self.rest(); 238 | let pos = rest.iter().position(|t| f(t.kind))?; 239 | let s = &rest[..pos]; 240 | self.current += pos; 241 | Some(s) 242 | } 243 | 244 | /// Consumes while the closure returns true or the block ends 245 | pub(crate) fn consume_while(&mut self, f: impl Fn(TokenKind) -> bool) -> &'t [Token] { 246 | let rest = self.rest(); 247 | let pos = rest.iter().position(|t| !f(t.kind)).unwrap_or(rest.len()); 248 | let s = &rest[..pos]; 249 | self.current += pos; 250 | s 251 | } 252 | 253 | pub(crate) fn ws_comments(&mut self) -> &'t [Token] { 254 | self.consume_while(|t| matches!(t, T![ws] | T![line comment] | T![block comment])) 255 | } 256 | 257 | /// Call [`Self::next_token`] if the next token is `expected`. 258 | #[must_use] 259 | pub(crate) fn consume(&mut self, expected: TokenKind) -> Option { 260 | if self.at(expected) { 261 | Some(self.bump_any()) 262 | } else { 263 | None 264 | } 265 | } 266 | 267 | pub(crate) fn error(&mut self, error: SourceDiag) { 268 | debug_assert!(error.is_error()); 269 | self.event(Event::Error(error)) 270 | } 271 | pub(crate) fn warn(&mut self, warn: SourceDiag) { 272 | debug_assert!(warn.is_warning()); 273 | self.event(Event::Warning(warn)) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Unreleased - ReleaseDate 4 | 5 | ## 0.17.3 6 | - Fixes references components by @mawo66 in https://github.com/cooklang/cooklang-rs/pull/81 7 | 8 | ## 0.17.0 9 | - Softens YAML frontmatter parsing by @dubadub in https://github.com/cooklang/cooklang-rs/pull/57 10 | - Adds pantry config 11 | 12 | ## 0.16.6 - 2025/08/11 13 | 14 | - (breaking) Remove generics from `Recipe`. Now recipes are scalable multiple times. 15 | - (breaking) Remove `Recipe::default_scale`. 16 | - (breaking) Scaling is now infallible, text values are ignored. Removed 17 | `ScaleOutcome`, if this functionality was needed, it can easily 18 | be replaced by checking if the quatity is text or not scalable. 19 | - Cookware now can have full quantities with units, not just values. 20 | - Servings value in metadata now isn't a vector 21 | 22 | ## 0.16.3 - 2025/07/28 23 | 24 | - Fixes scaling behavior in metadata servings by @dubadub in https://github.com/cooklang/cooklang-rs/pull/43 25 | - Adds lenient aisle parsing to allow more flexible formatting by @dubadub in https://github.com/cooklang/cooklang-rs/pull/44 26 | - Softens canonical parser to reduce strictness on edge cases by @dubadub in https://github.com/cooklang/cooklang-rs/pull/45 27 | 28 | ## 0.16.1 - 2025/05/27 29 | 30 | - Adds references support into IngredientList by @dubadub in https://github.com/cooklang/cooklang-rs/pull/36 31 | 32 | ## 0.16.0 - 2025/03/27 33 | 34 | - Correct spelling `ouput` -> `output` and `bunlded` -> `bundled` by @melusc in https://github.com/cooklang/cooklang-rs/pull/31 35 | - Don't hide servings for input by default in playground by @melusc in https://github.com/cooklang/cooklang-rs/pull/32 36 | - Enable scaling according to new spec changes by @dubadub in https://github.com/cooklang/cooklang-rs/pull/30 37 | - Allow referencing other recipes by @dubadub in https://github.com/cooklang/cooklang-rs/pull/34 38 | - (breaking) Use floating value for scaling factor instead of base and target 39 | servings by @dubadub in https://github.com/cooklang/cooklang-rs/pull/35 40 | 41 | ## 0.15.0 - 2025/01/14 42 | 43 | - Add support in `cooklang::metadata` for [canonical 44 | metadata](https://cooklang.org/docs/spec/#canonical-metadata), making it 45 | easier to query these keys and expected values. 46 | - Add warnings for missused canonical metadata keys. 47 | - Improve custom checks for metadata keys. Now they can choose to skip the 48 | included checks too. 49 | - Fix ingredients aliases from aisle configuration not being merged in 50 | `IngredientList`. (#24 @kaylee-kiako) 51 | - Remove many dependencies, binaries should be smaller. 52 | - (breaking) Change `Quantity` API. 53 | - `value` is not a getter. 54 | - `unit` returns the unit text 55 | - `unit_info` (new method) returns the `Unit` value with a runtime lookup. 56 | - (breaking) Rename `TEMPERATURE` extension with `INLINE_QUANTITIES`. Now all 57 | inline quantities are found, not only temperatures. You may need to update 58 | your application to handle this. 59 | 60 | ## 0.14.0 - 2024/12/11 61 | 62 | - Add YAML frontmatter for metadata. Deprecate old style metadata keys with the 63 | `>>` syntax. This also comes with changes in the `cooklang::metadata` module. 64 | - Add deprecation and how to fix warnings when using old style metadata. 65 | - Remove `MULTILINE_STEPS`, `COMPONENT_NOTE`, `SECTIONS` and `TEXT_STEPS` 66 | **extensions** as they are now part of the cooklang specification and are 67 | always enabled. 68 | 69 | ## 0.13.3 - 2024/08/12 70 | - Replace `ariadne` dependency with `codesnake`. Because of this, errors may 71 | have some minor differences. 72 | 73 | ## 0.13.2 - 2024/04/07 74 | - Fixed name and url parsing in `author` and `source` special metadata keys. 75 | Before, the name was too restrictive and some names could be miss interpreted 76 | as URLs. (thanks to @Someone0nEarth) 77 | 78 | ## 0.13.1 79 | ### Fixed 80 | - Panic when parsing just metadata. 81 | 82 | ## 0.13.0 83 | ### Features 84 | - The parser now has the option to check every metadata entry with a custom 85 | function. See `ParseOptions`. 86 | 87 | ### Breaking 88 | - Replace recipe ref checks API with `ParseOptions`. This now also holds the 89 | metadata validator. 90 | - Tags are no longer check. Use a custom entry validator if you need it. 91 | 92 | ## 0.12.0 - 2024-01-13 93 | ### Features 94 | - Special metadata keys are now an extension. 95 | - Improve `Metadata` memory layout and interface. 96 | - Emoji can now also be a shortcode like `:taco:`. 97 | 98 | ### Breaking 99 | - (De)Serializing `ScaleOutcome` was not camel case, so (de)serialization has changed 100 | from previous versions. 101 | - (De)Serializing format change of `Metadata` special values. Now all special 102 | key values whose parsed values have some different representation are under 103 | the `special` field. 104 | - Removed all fields except `map` from `Metadata`, now they are methods. 105 | 106 | ## 0.11.1 - 2023-12-28 107 | ### Fixed 108 | - Add missing auto traits to `SourceReport` and all of it's dependent structs. 109 | Notably, it was missing `Send` and `Sync`, which were implemented in 110 | previous releases. 111 | 112 | ## 0.11.0 - 2023-12-26 113 | ### Breaking changes 114 | - Remove `PassResult::take_output`. 115 | - `Metadata::map_filtered` now returns an iterator instead of a copy of the map. 116 | 117 | ### Fixed 118 | - Implement `Clone` for `PassResult`. 119 | 120 | ## 0.10.0 - 2023-12-17 121 | ### Breaking changes 122 | - Reworked intermediate references. Index is gone, now you reference the step or 123 | section number directly. Text steps can't be referenced now. 124 | - Rename `INTERMEDIATE_INGREDIENTS` extension to `INTERMEDIATE_PREPARATIONS`. 125 | - Sections now holds content: steps and text blocks. This makes a clear 126 | distinction between the old regular steps and text steps which have been 127 | removed. 128 | - Remove name from `Recipe`. The name in cooklang is external to the recipe and 129 | up to the library user to handle it. 130 | - Remove `analysis::RecipeContent`. Now `analysis::parse_events` returns a 131 | `ScalableRecipe` directly. 132 | - Change the return type of the recipe ref checker. 133 | - Reworked error model. 134 | - Removed `Ingredient::total_quantity`. 135 | - Change `Cookware::group_amounts` return type. 136 | - Several changes in UnitsFile: 137 | - System is no longer set when declaring a unit with an unspecified system as best of a specific system. 138 | - `extend.names`, `extend.aliases` and `extend.symbols` are now combined in `extend.units`. 139 | - Removed `UnitCount` and `Converter::unit_count_detailed`. 140 | - Removed `hide_warnings` arg from `SourceReport` `write`, `print` and `eprint` methods. 141 | Use `SourceReport::zip` or `SourceReport::remove_warnings`. 142 | 143 | ### Features 144 | - New warning for bad single word names. It could be confusing not getting any 145 | result because of a unsoported symbol there. 146 | - Improve redundant modifiers warnings. 147 | - Recipe not found warning is now customizable from the result of the recipe ref 148 | checker. 149 | - Unknown special metadata keys are now added to the metadata. 150 | - Advanced units removal of `%` now supports range values too. 151 | - New error for text value in a timer with the advanced units extension. 152 | - Special metadata keys for time, now use the configured time units. When no 153 | units are loaded, fallback unit times are used just for this. 154 | - Bundled units now includes `secs` and `mins` as aliases to seconds and 155 | minutes. 156 | - New warning for overriding special recipe total time with composed time and 157 | vice versa. 158 | - Added `ScaledRecipe::group_cookware`. 159 | - Rework `GroupedQuantity` API and add `GroupedValue`. 160 | - Ignored ingredients in text mode are now added as text. 161 | - Several features in UnitsFile to make it more intuitive: 162 | - The best unit of a system can now be from any system. It's up to the user if 163 | they want to mix them. 164 | - New `extend.units`, which allows to edit the conversions. 165 | - Improve and actually make usable the fractions configuration. Now with an 166 | `all` and `quantity.` options. 167 | - An empty unit after the separator (%) is now a warning and it counts as there 168 | is no unit. 169 | - Added `SourceReport::remove_warnings`. 170 | 171 | ### Fixed 172 | - Text steps were ignored in `components` mode. 173 | - Scale text value error was firing for all errors marked with `*`. 174 | - Even though number values for quantities were decimal, a big integer would 175 | fail to parse. That's no more the case. If it's too big, it will only fail in 176 | a fraction. 177 | - Incorrect behaviour with single word components that started with a decimal 178 | number. 179 | 180 | ## 0.9.0 - 2023-10-07 181 | ### Features 182 | - Better support for fractions in the parser. 183 | - `Quantity` `convert`/`fit` now tries to use a fractional value when needed. 184 | 185 | ### Changes 186 | - Use US customary units for imperial units in the bundled `units.toml` file. 187 | - Expose more `Converter` methods. 188 | 189 | ### Breaking changes 190 | - Several model changes from struct enums to tuple enums and renames. 191 | 192 | ## 0.8.0 - 2023-09-26 193 | ### Features 194 | - New warnings for metadata and sections blocks that failed to parse and are 195 | treated as text. 196 | ### Breaking changes 197 | - The `servings` metadata value now rejects when a duplicate amount is given 198 | ``` 199 | >> servings: 14 | 14 -- this rejects and raise a warning 200 | ``` 201 | - `CooklangError`, `CooklangWarning`, `ParserError`, `ParserWarning`, 202 | `AnalysisError`, `AnalysisWarning`, `MetadataError` and `Metadata` are now 203 | `non_exhaustive`. 204 | ### Fixed 205 | - `Metadata::map_filtered` was filtering `slug`, an old special key. 206 | 207 | ## 0.7.1 - 2023-08-28 208 | ### Fixed 209 | - Only the first temperature in a parser `Text` event was being parsed 210 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A [cooklang](https://cooklang.org/) parser with opt-in extensions. 2 | //! 3 | //! The extensions create a superset of the original cooklang language and can 4 | //! be turned off. To see a detailed list go to [extensions](_extensions). 5 | //! 6 | //! Also includes: 7 | //! - Rich error report with annotated code spans. 8 | //! - Unit conversion. 9 | //! - Recipe scaling. 10 | //! - A parser for cooklang aisle configuration file. 11 | //! 12 | //! # Basic usage 13 | //! If you just want **to parse a single** `cooklang` file, see [`parse`]. 14 | //! 15 | //! If you are going to parse more than one, or want to change the 16 | //! configuration of the parser, construct a parser instance yourself. 17 | //! 18 | //! To construct a parser use [`CooklangParser::new`] or 19 | //! [`CooklangParser::default`] if you want to configure the parser. You can 20 | //! configure which [`Extensions`] are enabled and the [`Converter`] used to 21 | //! convert and check units. 22 | //! 23 | //! ```rust 24 | //! # use cooklang::{CooklangParser, Converter, Extensions}; 25 | //! // Create a parser 26 | //! // (this is the default configuration) 27 | //! let parser = CooklangParser::new(Extensions::all(), Converter::default()); 28 | //! # assert_eq!(parser, CooklangParser::default()); 29 | //! ``` 30 | //! 31 | //! Then use the parser: 32 | //! 33 | //! ```rust 34 | //! # use cooklang::CooklangParser; 35 | //! # let parser = CooklangParser::default(); 36 | //! let (recipe, _warnings) = parser.parse("This is an @example").into_result()?; 37 | //! assert_eq!(recipe.ingredients.len(), 1); 38 | //! assert_eq!(recipe.ingredients[0].name, "example"); 39 | //! # assert!(_warnings.is_empty()); 40 | //! # Ok::<(), cooklang::error::SourceReport>(()) 41 | //! ``` 42 | //! 43 | //! Recipes can be scaled and converted. But the following applies: 44 | //! - Parsing returns a [`ScalableRecipe`]. 45 | //! - Only [`ScalableRecipe`] can be [`scaled`](ScalableRecipe::scale) or 46 | //! [`default_scaled`](ScalableRecipe::default_scale) **only once** to obtain 47 | //! a [`ScaledRecipe`]. 48 | //! - Only [`ScaledRecipe`] can be [`converted`](ScaledRecipe::convert). 49 | 50 | #![warn(rustdoc::broken_intra_doc_links, clippy::doc_markdown)] 51 | 52 | #[cfg(doc)] 53 | pub mod _extensions { 54 | #![doc = include_str!("../extensions.md")] 55 | } 56 | 57 | #[cfg(doc)] 58 | pub mod _features { 59 | //! This lib has 3 features, 2 enabled by default: 60 | //! - `bundled_units`. Includes a units file with the most common units for 61 | //! recipes in English. These units are available to load when you want 62 | //! without the need to read a file. The default 63 | //! [`Converter`](crate::convert::Converter) use them if this feature is 64 | //! enabled. [This is the bundled file](https://github.com/cooklang/cooklang-rs/blob/main/units.toml) 65 | //! 66 | //! - `aisle`. Enables the [`aisle`](crate::aisle) module. 67 | //! 68 | //! - `pantry`. Enables the [`pantry`](crate::pantry) module. 69 | } 70 | 71 | #[cfg(feature = "aisle")] 72 | pub mod aisle; 73 | pub mod analysis; 74 | pub mod ast; 75 | pub mod convert; 76 | pub mod error; 77 | pub mod ingredient_list; 78 | pub mod located; 79 | pub mod metadata; 80 | pub mod model; 81 | #[cfg(feature = "pantry")] 82 | pub mod pantry; 83 | pub mod parser; 84 | pub mod quantity; 85 | pub mod scale; 86 | pub mod span; 87 | pub mod text; 88 | 89 | mod lexer; 90 | 91 | use bitflags::bitflags; 92 | use serde::{Deserialize, Serialize}; 93 | 94 | use error::PassResult; 95 | 96 | pub use analysis::ParseOptions; 97 | pub use convert::Converter; 98 | pub use located::Located; 99 | pub use metadata::Metadata; 100 | pub use model::*; 101 | pub use parser::Modifiers; 102 | pub use quantity::{GroupedQuantity, Quantity, Value}; 103 | pub use span::Span; 104 | pub use text::Text; 105 | 106 | bitflags! { 107 | /// Extensions bitflags 108 | /// 109 | /// This allows to enable or disable the extensions. See [extensions](_extensions) 110 | /// for a detailed explanation of all of them. 111 | /// 112 | /// [`Extensions::default`] enables all extensions. 113 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 114 | pub struct Extensions: u32 { 115 | /// Enables the [`Modifiers`](crate::ast::Modifiers) 116 | const COMPONENT_MODIFIERS = 1 << 1; 117 | /// Alias with `@igr|alias{}` 118 | const COMPONENT_ALIAS = 1 << 3; 119 | /// Enable extra checks with units and allows to omit the `%` in simple 120 | /// cases like `@igr{10 kg}` 121 | const ADVANCED_UNITS = 1 << 5; 122 | /// Set the parsing mode with special metadata keys 123 | /// `>> [key inside square brackets]: value` 124 | const MODES = 1 << 6; 125 | /// Searches for inline quantities in all the recipe text 126 | const INLINE_QUANTITIES = 1 << 7; 127 | /// Add support for range values `@igr{2-3}` 128 | const RANGE_VALUES = 1 << 9; 129 | /// Creating a timer without a time becomes an error 130 | const TIMER_REQUIRES_TIME = 1 << 10; 131 | /// This extensions also enables [`Self::COMPONENT_MODIFIERS`]. 132 | const INTERMEDIATE_PREPARATIONS = 1 << 11 | Self::COMPONENT_MODIFIERS.bits(); 133 | 134 | /// Enables a subset of extensions to maximize compatibility with other 135 | /// cooklang parsers. 136 | /// 137 | /// Currently it enables all the extensions except 138 | /// [`Self::TIMER_REQUIRES_TIME`]. 139 | /// 140 | /// **ADDITIONS TO THE EXTENSIONS THIS ENABLES WILL NOT BE CONSIDERED A BREAKING CHANGE** 141 | const COMPAT = Self::COMPONENT_MODIFIERS.bits() 142 | | Self::COMPONENT_ALIAS.bits() 143 | | Self::ADVANCED_UNITS.bits() 144 | | Self::MODES.bits() 145 | | Self::INLINE_QUANTITIES.bits() 146 | | Self::RANGE_VALUES.bits() 147 | | Self::INTERMEDIATE_PREPARATIONS.bits(); 148 | } 149 | } 150 | 151 | impl Default for Extensions { 152 | /// Enables all extensions 153 | fn default() -> Self { 154 | Self::all() 155 | } 156 | } 157 | 158 | /// A cooklang parser 159 | /// 160 | /// Instantiating this takes time and the first parse may take longer. So 161 | /// you may want to create only one and reuse it. 162 | /// 163 | /// The default parser enables all extensions. 164 | /// 165 | /// The 2 main methods are [`CooklangParser::parse`] and [`CooklangParser::parse_metadata`]. 166 | /// 167 | /// You can also skip using this struct and use [`parser::PullParser`] and [`analysis::parse_events`]. 168 | #[derive(Debug, Default, Clone, PartialEq)] 169 | pub struct CooklangParser { 170 | extensions: Extensions, 171 | converter: Converter, 172 | } 173 | 174 | pub type RecipeResult = PassResult; 175 | pub type MetadataResult = PassResult; 176 | 177 | impl CooklangParser { 178 | /// Creates a new parser. 179 | /// 180 | /// It is encouraged to reuse the parser and not rebuild it every time. 181 | pub fn new(extensions: Extensions, converter: Converter) -> Self { 182 | Self { 183 | extensions, 184 | converter, 185 | } 186 | } 187 | 188 | /// Creates a new extended parser 189 | /// 190 | /// This enables all extensions and uses the bundled units. 191 | /// It is encouraged to reuse the parser and not rebuild it every time. 192 | #[cfg(feature = "bundled_units")] 193 | pub fn extended() -> Self { 194 | Self::new(Extensions::all(), Converter::bundled()) 195 | } 196 | 197 | /// Creates a new canonical parser 198 | /// 199 | /// This disables all extensions and does not use units. 200 | /// 201 | /// It is encouraged to reuse the parser and not rebuild it every time. 202 | pub fn canonical() -> Self { 203 | Self::new(Extensions::empty(), Converter::empty()) 204 | } 205 | 206 | /// Get the parser inner converter 207 | pub fn converter(&self) -> &Converter { 208 | &self.converter 209 | } 210 | 211 | /// Get the enabled extensions 212 | pub fn extensions(&self) -> Extensions { 213 | self.extensions 214 | } 215 | 216 | /// Parse a recipe 217 | pub fn parse(&self, input: &str) -> RecipeResult { 218 | self.parse_with_options(input, ParseOptions::default()) 219 | } 220 | 221 | /// Same as [`Self::parse`] but with aditional options 222 | #[tracing::instrument(level = "debug", name = "parse", skip_all, fields(len = input.len()))] 223 | pub fn parse_with_options(&self, input: &str, options: ParseOptions) -> RecipeResult { 224 | let mut parser = parser::PullParser::new(input, self.extensions); 225 | analysis::parse_events( 226 | &mut parser, 227 | input, 228 | self.extensions, 229 | &self.converter, 230 | options, 231 | ) 232 | } 233 | 234 | /// Parse only the metadata of a recipe 235 | /// 236 | /// This is a bit faster than [`Self::parse`] if you only want the metadata 237 | pub fn parse_metadata(&self, input: &str) -> MetadataResult { 238 | self.parse_metadata_with_options(input, ParseOptions::default()) 239 | } 240 | 241 | /// Same as [`Self::parse_metadata`] but with aditional options 242 | #[tracing::instrument(level = "debug", name = "metadata", skip_all, fields(len = input.len()))] 243 | pub fn parse_metadata_with_options( 244 | &self, 245 | input: &str, 246 | options: ParseOptions, 247 | ) -> MetadataResult { 248 | let parser = parser::PullParser::new(input, self.extensions); 249 | let meta_events = parser.into_meta_iter(); 250 | analysis::parse_events( 251 | meta_events, 252 | input, 253 | self.extensions, 254 | &self.converter, 255 | options, 256 | ) 257 | .map(|c| c.metadata) 258 | } 259 | } 260 | 261 | /// Parse a recipe with a default [`CooklangParser`]. Avoid calling this in a loop. 262 | /// 263 | /// The default parser enables all extensions. 264 | /// 265 | /// **IMPORTANT:** If you are going to parse more than one recipe you may want 266 | /// to only create one [`CooklangParser`] and reuse it. Every time this function 267 | /// is called, an instance of a parser is constructed. Depending on the 268 | /// configuration, creating an instance and the first call to that can take much 269 | /// longer than later calls to [`CooklangParser::parse`]. 270 | pub fn parse(input: &str) -> RecipeResult { 271 | CooklangParser::default().parse(input) 272 | } 273 | --------------------------------------------------------------------------------