From 54573dcd210ef2bb58d5cb2ca4dcb73d930fbc25 Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Thu, 22 Oct 2026 21:45:45 +0200 Subject: [PATCH] fix: report warning instead of throwing when encountering a mix of text and numeric quantities --- docs/e/codes/COOKLANG_E9401.md | 8 ++-- docs/e/codes/COOKLANG_E9402.md | 22 ++++++++-- docs/e/codes/COOKLANG_E9403.md | 31 -------------- docs/e/codes/COOKLANG_W0401.md | 17 ++++++++ docs/guide-diagnostics.md | 6 +-- src/classes/recipe.ts | 43 +++++++++++++++---- src/diagnostics.ts | 8 ++-- src/errors.ts | 6 +-- src/quantities/alternatives.ts | 33 ++++++++++++++- test/diagnostics.test.ts | 76 ++++++++++++++++++++++++++++++++++ test/recipe_parsing.test.ts | 19 ++++++++- 11 files changed, 209 insertions(+), 60 deletions(-) delete mode 100644 docs/e/codes/COOKLANG_E9403.md create mode 100644 docs/e/codes/COOKLANG_W0401.md diff --git a/docs/e/codes/COOKLANG_E9401.md b/docs/e/codes/COOKLANG_E9401.md index 8a3a3ede..dd608463 100644 --- a/docs/e/codes/COOKLANG_E9401.md +++ b/docs/e/codes/COOKLANG_E9401.md @@ -1,17 +1,17 @@ --- code: COOKLANG_E9401 level: error(fatal) -title: Text quantity cannot be added +title: Ratio cannot be computed --- ## What this error means -A quantity with a text value (e.g. `a pinch`) cannot be added to another quantity. This aborts the whole `parse()`/`scaleBy()`/`convertTo()` call; it is not collected alongside other diagnostics. +The ratio between two quantities cannot be computed because one of them is not numeric. This aborts the whole `parse()`/`scaleBy()`/`convertTo()` call; it is not collected alongside other diagnostics. ## Common causes -- The same ingredient (without alternatives) appears more than once, once with a text quantity and once with a numeric quantity of a compatible unit, e.g. `@salt{a pinch%tsp}` then `@&salt{2%tsp}` +- Merging equivalents where one of the equivalent quantities is text-only, e.g. `@flour{1%cup|a bit}` then `@&flour{2%cup|a lot}` ## How to fix it -Use numeric quantities throughout when the same ingredient's quantities need to be summed, or use `&`-less separate entries / alternatives if they are not meant to be added together. +Use numeric quantities for every equivalent that needs to be merged with another occurrence of the same ingredient. diff --git a/docs/e/codes/COOKLANG_E9402.md b/docs/e/codes/COOKLANG_E9402.md index 92f61a43..c7f60c72 100644 --- a/docs/e/codes/COOKLANG_E9402.md +++ b/docs/e/codes/COOKLANG_E9402.md @@ -1,17 +1,31 @@ --- code: COOKLANG_E9402 level: error(fatal) -title: Ratio cannot be computed +title: Division by zero --- ## What this error means -The ratio between two quantities cannot be computed because one of them is not numeric. This aborts the whole `parse()`/`scaleBy()`/`convertTo()` call; it is not collected alongside other diagnostics. +A fraction in the recipe has a zero denominator, so its value is undefined. +The parser stops immediately rather than continuing with a partial parse. ## Common causes -- Merging equivalents where one of the equivalent quantities is text-only, e.g. `@flour{1%cup|a bit}` then `@&flour{2%cup|a lot}` +- Writing a fraction such as `{1/0}` ## How to fix it -Use numeric quantities for every equivalent that needs to be merged with another occurrence of the same ingredient. +```text +-- Wrong +Add @flour{1/0} +-- Correct +Add @flour{1/2} +``` + +Use a nonzero denominator. + +## Example output + +``` +error[COOKLANG_E1003]: A fraction cannot have a zero denominator. +``` diff --git a/docs/e/codes/COOKLANG_E9403.md b/docs/e/codes/COOKLANG_E9403.md deleted file mode 100644 index 5b8aac99..00000000 --- a/docs/e/codes/COOKLANG_E9403.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -code: COOKLANG_E9403 -level: error(fatal) -title: Division by zero ---- - -## What this error means - -A fraction in the recipe has a zero denominator, so its value is undefined. -The parser stops immediately rather than continuing with a partial parse. - -## Common causes - -- Writing a fraction such as `{1/0}` - -## How to fix it - -```text --- Wrong -Add @flour{1/0} --- Correct -Add @flour{1/2} -``` - -Use a nonzero denominator. - -## Example output - -``` -error[COOKLANG_E1003]: A fraction cannot have a zero denominator. -``` diff --git a/docs/e/codes/COOKLANG_W0401.md b/docs/e/codes/COOKLANG_W0401.md new file mode 100644 index 00000000..e230c7bc --- /dev/null +++ b/docs/e/codes/COOKLANG_W0401.md @@ -0,0 +1,17 @@ +--- +code: COOKLANG_W0401 +level: warning +title: Text quantities kept separate +--- + +## What this warning means + +A numeric quantity and a text-valued quantity were found for the same ingredient. They cannot be added together, so they are kept separate. + +## Common causes + +- Referencing an ingredient with both a numeric quantity and a descriptive quantity, such as `@salt{2}` and `@&salt{a pinch}`, or `@salt{2%g}` and `@&salt{a pinch}` + +## How to fix it + +No change is needed if both quantities are intentional; they remain available separately. If they should be summed, use numeric quantities for both. \ No newline at end of file diff --git a/docs/guide-diagnostics.md b/docs/guide-diagnostics.md index 93c66a20..a3b68f8f 100644 --- a/docs/guide-diagnostics.md +++ b/docs/guide-diagnostics.md @@ -73,9 +73,9 @@ Parse-time diagnostics, reported by `Recipe.parse()`: | [`COOKLANG_E0301`](/e/cooklang_e0301) | error | Metadata indentation contains a tab. | | [`COOKLANG_E0302`](/e/cooklang_e0302) | error | Metadata indentation levels are inconsistent. | | [`COOKLANG_W0301`](/e/cooklang_w0301) | warning | `servings` or `serves` is not a number. | -| [`COOKLANG_E9401`](/e/cooklang_e9401) | error (fatal) | A text quantity (e.g. "a pinch") could not be merged with another occurrence of the same ingredient. | -| [`COOKLANG_E9402`](/e/cooklang_e9402) | error (fatal) | A text equivalent could not be merged with another occurrence of the same ingredient. | -| [`COOKLANG_E9403`](/e/cooklang_e9403) | error (fatal) | A fraction has a zero denominator; parsing stops immediately. | +| [`COOKLANG_E9401`](/e/cooklang_e9401) | error (fatal) | A text equivalent could not be merged with another occurrence of the same ingredient. | +| [`COOKLANG_E9402`](/e/cooklang_e9402) | error (fatal) | A fraction has a zero denominator; parsing stops immediately. | +| [`COOKLANG_W0401`](/e/cooklang_w0401) | warning | A text quantity could not be added to a numeric quantity and was kept separate. | Warnings never make `parse()` throw. Non-fatal errors (`E0…`) are collected alongside everything else so that the whole recipe gets reported in one diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts index 82bafe02..f3d06c2d 100644 --- a/src/classes/recipe.ts +++ b/src/classes/recipe.ts @@ -436,13 +436,15 @@ export class Recipe { try { return parseQuantityValue(quantity); } catch (error) { + // v8 ignore else -- @preserve: DivisionByZeroError is the only possible error if (error instanceof DivisionByZeroError) { throw this._toParseStoppedError( error, makeSpan(this._lineOffsets, lineIdx, column, length), ); + } else { + throw error; } - throw error; } } @@ -878,7 +880,7 @@ export class Recipe { * * @internal */ - private _populateIngredientQuantities(): void { + private _populateIngredientQuantities(collectDiagnostics = false): void { // Reset quantities and usedAsPrimary flag for (const ing of this.ingredients) { delete ing.quantities; @@ -886,7 +888,13 @@ export class Recipe { } // Get ingredients with quantities using default (no explicit choice = primary with alternatives) - const ingredientsWithQuantities = this.getIngredientQuantities(); + const { ingredients: ingredientsWithQuantities, textMixCount } = + this._computeIngredientQuantities({}); + if (collectDiagnostics) { + for (let i = 0; i < textMixCount; i++) { + this.diagnostics.push(createDiagnostic("COOKLANG_W0401", {})); + } + } // Track which indices have been matched (for handling duplicate names) const matchedIndices = new Set(); @@ -1414,6 +1422,19 @@ export class Recipe { getIngredientQuantities( options?: GetIngredientQuantitiesOptions, ): Ingredient[] { + return this._computeIngredientQuantities(options).ingredients; + } + + private _computeIngredientQuantities( + options?: GetIngredientQuantitiesOptions, + ): { + ingredients: Ingredient[]; + textMixCount: number; + } { + let textMixCount = 0; + const onTextQuantityMix = () => { + textMixCount++; + }; const { ingredientGroups, selectedIndices, @@ -1456,7 +1477,11 @@ export class Recipe { for (const [, group] of groupsForIng) { const summed = group.quantities.length > 0 - ? addEquivalentsAndSimplify(group.quantities, this.unitSystem) + ? addEquivalentsAndSimplify( + group.quantities, + this.unitSystem, + onTextQuantityMix, + ) : undefined; const flattened = summed ? flattenPlainUnitGroup(summed) : []; @@ -1470,7 +1495,11 @@ export class Recipe { index: altIdx, ...(altQtys.length > 0 && { quantities: flattenPlainUnitGroup( - addEquivalentsAndSimplify(altQtys, this.unitSystem), + addEquivalentsAndSimplify( + altQtys, + this.unitSystem, + onTextQuantityMix, + ), ).flatMap( /* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */ (item) => ("quantity" in item ? [item] : item.and), @@ -1513,7 +1542,7 @@ export class Recipe { result.push(ing); } - return result; + return { ingredients: result, textMixCount }; } /** @@ -2043,7 +2072,7 @@ export class Recipe { this.choices.variants = [...allVariants]; } - this._populateIngredientQuantities(); + this._populateIngredientQuantities(true); // Diagnostics are reported in source order (sort is stable) this.diagnostics.sort( diff --git a/src/diagnostics.ts b/src/diagnostics.ts index ce543877..5cc3b82a 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -89,15 +89,15 @@ export const diagnosticCatalog = defineDiagnostics({ `Cannot add quantities with incompatible or unknown units: ${p.unit1} and ${p.unit2}`, fix: `This should not be reachable from normal library usage. Please report it with a reproduction.`, }, - COOKLANG_E9401: { - why: `Cannot add a quantity with a text value.`, + COOKLANG_W0401: { + why: `Text-valued quantities could not be combined and were kept separate.`, fix: `Use numeric quantities if they need to be added together.`, }, - COOKLANG_E9402: { + COOKLANG_E9401: { why: `One of both values is not a number, so a ratio cannot be computed`, fix: `Use numeric quantities.`, }, - COOKLANG_E9403: { + COOKLANG_E9402: { why: `A fraction cannot have a zero denominator.`, fix: `Use a nonzero denominator, e.g. 1/2.`, }, diff --git a/src/errors.ts b/src/errors.ts index a34f2c11..c29aba06 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -161,7 +161,7 @@ export class DivisionByZeroError extends SingleDiagnosticError { readonly fraction: string; constructor(fraction: string) { - super(createDiagnostic("COOKLANG_E9403", {})); + super(createDiagnostic("COOKLANG_E9402", {})); this.name = "DivisionByZeroError"; this.fraction = fraction; } @@ -213,7 +213,7 @@ export class BadIndentationError extends SingleDiagnosticError { */ export class CannotAddTextValueError extends SingleDiagnosticError { constructor() { - super(createDiagnostic("COOKLANG_E9401", {})); + super(createDiagnostic("COOKLANG_W0401", {})); this.name = "CannotAddTextValueError"; } } @@ -239,7 +239,7 @@ export class IncompatibleUnitsError extends SingleDiagnosticError { */ export class RatioNotComputableError extends SingleDiagnosticError { constructor() { - super(createDiagnostic("COOKLANG_E9402", {})); + super(createDiagnostic("COOKLANG_E9401", {})); this.name = "RatioNotComputableError"; } } diff --git a/src/quantities/alternatives.ts b/src/quantities/alternatives.ts index 6b555159..3e1ec333 100644 --- a/src/quantities/alternatives.ts +++ b/src/quantities/alternatives.ts @@ -253,6 +253,7 @@ export function addQuantitiesOrGroups( QuantityWithExtendedUnit | FlatOrGroup )[], system?: SpecificUnitSystem, + onTextQuantityMix?: () => void, ): { sum: QuantityWithUnitDef | FlatAndGroup; unitsLists: QuantityWithUnitDef[][]; @@ -283,7 +284,30 @@ export function addQuantitiesOrGroups( // Step 3: calculate the sum const sum: QuantityWithUnitDef[] = []; for (const nextQ of reducedQuantities) { - const existingQ = findCompatibleQuantityWithinList(sum, nextQ); + const nextIsText = + nextQ.quantity.type === "fixed" && nextQ.quantity.value.type === "text"; + // Any numeric/text mix is reported, whatever the units + if ( + sum.some( + (q) => + (q.quantity.type === "fixed" && q.quantity.value.type === "text") !== + nextIsText, + ) + ) { + onTextQuantityMix?.(); + } + + // Text quantities are never merged, so only numeric entries are candidates + const existingQ = nextIsText + ? undefined + : findCompatibleQuantityWithinList( + sum.filter( + (q) => + !(q.quantity.type === "fixed" && q.quantity.value.type === "text"), + ), + nextQ, + ); + if (existingQ === undefined) { sum.push({ ...nextQ, @@ -400,12 +424,17 @@ export function addEquivalentsAndSimplify( QuantityWithExtendedUnit | FlatOrGroup )[], system?: SpecificUnitSystem, + onTextQuantityMix?: () => void, ): QuantityWithPlainUnit | MaybeNestedGroup { if (quantities.length === 1) { return toPlainUnit(quantities[0]!); } // Step 1+2+3: find equivalents, reduce groups and add quantities - const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system); + const { sum, unitsLists } = addQuantitiesOrGroups( + quantities, + system, + onTextQuantityMix, + ); // Step 4: regroup and expand equivalents per group const regrouped = regroupQuantitiesAndExpandEquivalents( sum, diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts index 80cd602e..ba3c8ed4 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -90,6 +90,82 @@ describe("Recipe.parse() diagnostics", () => { expect(result.diagnostics).toHaveLength(0); }); + it("keeps two text quantities separate without a warning", () => { + const result = parseCollect( + new Recipe(), + "Add @salt{a pinch}.\nAdd @&salt{to taste}.", + ); + + expect(result.diagnostics).toHaveLength(0); + const quantities = result.output.ingredients[0]?.quantities ?? []; + expect(quantities).toHaveLength(2); + expect(quantities[0]).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "text", text: "a pinch" }, + }, + }); + expect(quantities[1]).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, + }); + }); + + it("keeps mixed numeric and text quantities separate and collects a warning", () => { + const result = parseCollect( + new Recipe(), + "Add @salt{a pinch}.\nAdd @&salt{2}.", + ); + + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject>({ + code: "COOKLANG_W0401", + severity: "warning", + }); + const quantities = result.output.ingredients[0]?.quantities ?? []; + expect(quantities).toHaveLength(2); + expect(quantities[0]).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "text", text: "a pinch" }, + }, + }); + expect(quantities[1]).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + }); + + const result2 = parseCollect( + new Recipe(), + "Add @salt{2%g}.\nAdd @&salt{a pinch}.", + ); + + expect(result2.diagnostics).toHaveLength(1); + expect(result2.diagnostics[0]).toMatchObject>({ + code: "COOKLANG_W0401", + severity: "warning", + }); + const quantities2 = result2.output.ingredients[0]?.quantities ?? []; + expect(quantities2).toHaveLength(2); + expect(quantities2[0]).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "g", + }); + expect(quantities2[1]).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "text", text: "a pinch" }, + }, + }); + }); + it("maps spans to the original source across metadata and skipped lines", () => { const content = [ "---", diff --git a/test/recipe_parsing.test.ts b/test/recipe_parsing.test.ts index cfd01ba4..2d18f7b8 100644 --- a/test/recipe_parsing.test.ts +++ b/test/recipe_parsing.test.ts @@ -11,6 +11,7 @@ import { } from "./fixtures/recipes"; import type { Cookware, + Diagnostic, Ingredient, IngredientItem, Note, @@ -95,7 +96,7 @@ describe("parse function", () => { source, diagnostics: [ { - code: "COOKLANG_E9403", + code: "COOKLANG_E9402", severity: "error", span: { start: { offset: source.indexOf("@flour{1/0}") }, @@ -122,11 +123,24 @@ describe("parse function", () => { expect(() => recipe.parse(source)).toThrow(ParseStoppedError); expect(recipe.diagnostics[0]).toMatchObject({ - code: "COOKLANG_E9403", + code: "COOKLANG_E9402", severity: "error", }); }); + it("stops without a span when the zero-denominator fraction is full-width in metadata", () => { + const recipe = new Recipe(); + + expect(() => + recipe.parse("---\nyield: 1/0%kg\n---\nAdd @flour{1%kg}"), + ).toThrow(ParseStoppedError); + expect(recipe.diagnostics[0]).toMatchObject>({ + code: "COOKLANG_E9402", + severity: "error", + }); + expect(recipe.diagnostics[0]!.span).toBeUndefined(); + }); + it("extracts plain unquantified single-word ingredient correctly", () => { const result = new Recipe("@flour"); expect(result.ingredients).toEqual([ @@ -467,6 +481,7 @@ describe("parse function", () => { Then add some more @&flour{50%g}. `; const result = new Recipe(recipe); + expect(result.diagnostics).toHaveLength(0); expect(result.ingredients).toHaveLength(1); // Quantities are merged opportunistically when units are compatible expect(result.ingredients[0]).toEqual({