Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/e/codes/COOKLANG_W0201.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
code: COOKLANG_W0201
level: warning
title: Duplicate ingredient without reference
---

## What this error means

An ingredient with the same name, flags and preparation was already defined earlier in the recipe, but it was added again without the reference symbol `&`. A new, separate entry was added instead of updating the existing one.

## Common causes

- The same ingredient is listed twice with identical flags and preparation, without noticing an earlier definition
- The `&` was forgotten when intending to add more of an existing ingredient

## How to fix it

```text
-- Wrong
Add @?salt{1%tsp}(ground), then add @?salt{2%tsp}(ground).
-- Correct
Add @?salt{1%tsp}(ground), then add @&?salt{2%tsp}(ground).
```

Or keep the new entry if two separate occurrences are intentional.
6 changes: 5 additions & 1 deletion docs/guide-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ diagnostics while parsing continues, and the call returns the partial result
instead of throwing; `9` = fatal — it is not safe to continue, so the call
throws `DiagnosticError`). `T` is always `0` for `W` and `I` codes: warnings never
make the call fail, and internal errors always behave like fatal ones but
indicate a library bug rather than a problem with your input. `D` is the
indicate a library bug rather than a problem with your input. The tier
describes whether the call succeeds, not necessarily when parsing stops: the
parser stops immediately when it cannot safely continue, and may otherwise
finish collecting diagnostics before throwing. `D` is the
domain digit (`0` generic/internal, `1` syntax, `2` references, `3` metadata,
`4` quantities and units, `5` shopping list, `6` cart and product catalog,
`7` pantry and category configuration) and `NN` a 2-digit sequence number.
Expand All @@ -72,6 +75,7 @@ Parse-time diagnostics, reported by `Recipe.parse()`:
| [`COOKLANG_E0201`](/e/cooklang_e0201) | error | An ingredient reference has no earlier definition. |
| [`COOKLANG_E0202`](/e/cooklang_e0202) | error | A cookware reference has no earlier definition. |
| [`COOKLANG_E0203`](/e/cooklang_e0203) | error | A reference attempts to change a flag from its definition. |
| [`COOKLANG_W0201`](/e/cooklang_w0201) | warning | An unreferenced ingredient duplicates an earlier ingredient with the same flags and preparation. |
| [`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. |
Expand Down
23 changes: 23 additions & 0 deletions src/classes/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,14 @@ export class Recipe {
this.ingredients,
newIngredient,
reference,
this.diagnostics,
makeSpan(
this._lineOffsets,
lineIdx,
matchStart,
ingredientMatchString.length,
),
linkedVariants,
);
} catch (e) {
this._collectParseError(
Expand Down Expand Up @@ -748,6 +756,14 @@ export class Recipe {
this.ingredients,
newIngredient,
reference,
this.diagnostics,
makeSpan(
this._lineOffsets,
lineIdx,
matchStart,
ingredientMatchString.length,
),
linkedVariants,
);
} catch (e) {
this._collectParseError(
Expand Down Expand Up @@ -1988,6 +2004,13 @@ export class Recipe {
this.cookware,
newCookware,
reference,
this.diagnostics,
makeSpan(
this._lineOffsets,
lineIdx,
sourceColumn,
sourceLength,
),
);
// Adding the item itself in the preparation
const newItem: CookwareItem = {
Expand Down
5 changes: 5 additions & 0 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ export const diagnosticCatalog = defineDiagnostics({
fix: `Remove the reference ('&') to create a new entry, or add the flag to the original definition.`,
},
// -- Domain 3: metadata --------------------------------------------------
COOKLANG_W0201: {
why: (p: { name: string }) =>
`Ingredient "${p.name}" with the same flags and preparation is already present, but without the reference symbol "&" a new ingredient entry was added.`,
fix: `Add "&" before the ingredient name to reference the existing entry, or keep the new entry if it is intentional.`,
},
COOKLANG_E0301: {
why: `Tabs are not allowed for indentation in metadata blocks.`,
fix: `Replace tab characters with spaces.`,
Expand Down
48 changes: 47 additions & 1 deletion src/utils/parser_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type {
ArbitraryScalable,
FixedNumericValue,
Yield,
Diagnostic,
DiagnosticSourceSpan,
} from "../types";
import {
metadataRegex,
Expand Down Expand Up @@ -46,6 +48,7 @@ import {
} from "../errors";
import { parseTimeToMinutes } from "./time";
import { normalizeInputString, normalizeKey } from "./normalization";
import { createDiagnostic } from "../diagnostics";

/**
* Pushes a pending note to the section content if it has items.
Expand Down Expand Up @@ -96,19 +99,28 @@ export function flushPendingItems(
return false;
}

/** Variants under which each ingredient was declared (absent = all variants). */
const declaredVariants = new WeakMap<Ingredient, string[]>();

/**
* Finds an ingredient in the list (case-insensitively) and updates it, or adds it if not present.
* This function mutates the `ingredients` array.
* @param ingredients - The list of ingredients.
* @param newIngredient - The ingredient to find or add.
* @param isReference - Whether this is a reference ingredient (`&` modifier).
* @param diagnostics - The diagnostics list to push warnings onto.
* @param span - The source span of the new ingredient, used for diagnostics.
* @param linkedVariants - Variants the new ingredient is declared under; declarations in disjoint variants are not duplicates.
* @returns The index of the ingredient in the list.
* @returns An object containing the index of the ingredient and its quantity part in the list.
*/
export function findAndUpsertIngredient(
ingredients: Ingredient[],
newIngredient: Ingredient,
isReference: boolean,
diagnostics: Diagnostic[],
span?: DiagnosticSourceSpan,
linkedVariants?: string[],
): number {
const { name } = newIngredient;

Expand Down Expand Up @@ -156,14 +168,47 @@ export function findAndUpsertIngredient(
return indexFind;
}

// Checking for an existing ingredient with the same flags and preparation
const indexFind = ingredients.findIndex((existing) => {
const existingFlags = existing.flags ?? [];
const ingredientFlags = newIngredient.flags ?? [];

// Declarations restricted to disjoint variants are not duplicates
const existingVariants = declaredVariants.get(existing);
if (
existingVariants &&
linkedVariants &&
!existingVariants.some((v) => linkedVariants.includes(v))
) {
return false;
}

return (
normalizeKey(existing.name) === normalizeKey(newIngredient.name) &&
existing.preparation === newIngredient.preparation &&
existingFlags.length === ingredientFlags.length &&
ingredientFlags.every((flag) => existingFlags.includes(flag))
);
});

if (indexFind !== -1) {
diagnostics.push(
createDiagnostic("COOKLANG_W0201", { name: newIngredient.name }, span),
);
}

// Not a reference, so add as a new ingredient.
return ingredients.push(newIngredient) - 1;
const newIndex = ingredients.push(newIngredient) - 1;
if (linkedVariants) declaredVariants.set(newIngredient, linkedVariants);
return newIndex;
}

export function findAndUpsertCookware(
cookware: Cookware[],
newCookware: Cookware,
isReference: boolean,
diagnostics: Diagnostic[],
span?: DiagnosticSourceSpan,
): number {
const { name, quantity } = newCookware;

Expand Down Expand Up @@ -219,6 +264,7 @@ export function findAndUpsertCookware(
} catch (e) {
/* v8 ignore else -- expliciting error type -- @preserve */
if (e instanceof CannotAddTextValueError) {
diagnostics.push(createDiagnostic("COOKLANG_W0401", {}, span));
return cookware.push(newCookware) - 1;
}
}
Expand Down
40 changes: 40 additions & 0 deletions test/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,44 @@ describe("Recipe.parse() throwing", () => {
expect(error.message).toContain("1 error");
expect(error.diagnostics).toHaveLength(2);
});

it("warns when an unreferenced ingredient repeats with the same flags and preparation", () => {
const source = "Add @?salt{1%tsp}(ground), then add @?salt{2%tsp}(ground).";
const result = parseCollect(new Recipe(), source);

expect(result.diagnostics).toHaveDiagnostic("COOKLANG_W0201", {
times: 1,
match: {
severity: "warning",
span: {
start: {
offset: source.lastIndexOf("@?salt"),
line: 1,
},
},
},
});
expect(result.output.ingredients).toHaveLength(2);
});

it("does not warn when preparation or flags differ, or when the ingredient is referenced", () => {
const result = parseCollect(
new Recipe(),
"Add @?salt{1%tsp}(ground), @salt{1%tsp}(ground), " +
"@?salt{1%tsp}(crushed), then @&?salt{1%tsp}(ground).",
);

expect(result.diagnostics).not.toHaveDiagnostic("COOKLANG_W0201");
});

it("warns for duplicate ingredients added through group-key syntax", () => {
const result = parseCollect(
new Recipe(),
"Add @|spices|salt{1%tsp}(ground) and @|spices|salt{2%tsp}(ground).",
);

expect(result.diagnostics).toHaveDiagnostic("COOKLANG_W0201", {
times: 1,
});
});
});
Loading
Loading