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
11 changes: 7 additions & 4 deletions docs/e/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ import { withBase } from "vitepress";

# Diagnostic codes

Every error thrown by, and every diagnostic returned by, `@tmlmt/cooklang-parser` has a stable code of the form `COOKLANG_<S><D><NNN>`:
Every error thrown by, and every diagnostic returned by, `@tmlmt/cooklang-parser` has a stable code of the form `COOKLANG_<L><T><D><NN>`:

- `S` is the severity: `E` for an error, `W` for a warning
- `D` is the domain: `1` recipe syntax, `2` references, `3` metadata, `4` quantities and units, `5` shopping list, `6` shopping cart and product catalog, `7` pantry and category configuration, `9` fatal errors
- `NNN` is a sequence number
- `L` is the letter: `E` for an error, `W` for a warning, or `I` for an internal/library bug.
- `T` is a tier digit used only for `E` codes: `0` for recoverable errors, which are collected while parsing continues and do not make the call throw, so the partial result stays available. `9` for fatal errors, where it is not safe to continue and the call throws. It is always `0` for `W` and `I` codes.
- `D` is the domain: `0` generic/internal, `1` recipe syntax, `2` references, `3` metadata, `4` quantities and units, `5` shopping list, `6` shopping cart and product catalog, or `7` pantry and category configuration.
- `NN` is a two-digit sequence number.

Warnings never make a call fail. Internal errors indicate a library bug and behave like fatal errors. Fatal errors stop the affected work when detected and make the call throw a `DiagnosticError`, which carries the diagnostics collected up to that point.

See the [parse diagnostics guide](/guide-diagnostics) to learn how to consume them.

Expand Down
35 changes: 19 additions & 16 deletions docs/guide-diagnostics.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Parse diagnostics

`Recipe.parse()` (and the `Recipe` constructor) collects known recipe problems
as diagnostics. Warnings are available on `recipe.diagnostics`. Ordinarily, if
at least one diagnostic has error severity, a single `DiagnosticError` is
thrown once the whole source has been processed; the recipe instance keeps the
partial result. A zero-denominator fraction instead stops parsing immediately
and throws `ParseStoppedError`, a `DiagnosticError` subclass that identifies
the premature stop.
as diagnostics. All of them, including non-fatal errors, are available on
`recipe.diagnostics`, and the recipe instance holds the partial result. Only
fatal errors (`COOKLANG_E9…`) make `parse()` throw a `DiagnosticError`. A
zero-denominator fraction stops parsing immediately and throws
`ParseStoppedError`, a `DiagnosticError` subclass that identifies the
premature stop.

```typescript
import {
Expand All @@ -19,6 +19,8 @@ import {
const recipe = new Recipe();
try {
recipe.parse(source);
// Reaching this point means no fatal error occurred. Non-fatal errors and
// warnings may still be present in recipe.diagnostics.
} catch (error) {
if (error instanceof DiagnosticError) {
if (error instanceof ParseStoppedError) {
Expand All @@ -30,7 +32,7 @@ try {
}
}

// Non-fatal diagnostics (warnings) are always available
// Non-fatal errors and warnings are always available
console.log(recipe.diagnostics);
```

Expand All @@ -40,17 +42,17 @@ columns are 1-based. Offsets are 0-based UTF-16 code-unit indices into
`DiagnosticError.source`, which is exactly the input passed to `parse()`.

`CategoryConfig`, `Pantry` and `ProductCatalog` behave identically: `parse()`
and the constructors throw `DiagnosticError`, and expose `diagnostics` and
`source` on the instance.
and the constructors throw `DiagnosticError` only for fatal errors, and expose
all diagnostics and the `source` on the instance.

## Diagnostic codes

Codes follow the pattern `COOKLANG_<L><T><D><NN>`: `L` is the letter (`E` for
error, `W` for warning, `I` for internal/library bug); `T` is a tier digit
used only for `E` codes (`0` = non-fatal — caught and collected alongside
other diagnostics while parsing continues, so you get a complete picture of
every problem found; `9` = fatal — the result cannot be considered usable
once this is found). `T` is always `0` for `W` and `I` codes: warnings never
used only for `E` codes (`0` = recoverable — collected alongside other
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
domain digit (`0` generic/internal, `1` syntax, `2` references, `3` metadata,
Expand All @@ -77,9 +79,10 @@ Parse-time diagnostics, reported by `Recipe.parse()`:
| [`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
pass; fatal errors (`E9…`) abort the call immediately once encountered. The
Warnings and non-fatal errors (`E0…`) never make `parse()` throw: they are
collected so that the whole recipe gets reported in one pass, and you can
display them next to the partially parsed result. Fatal errors (`E9…`) make
the call throw. The
catalog and the severity of a code are available programmatically through
`diagnosticCatalog` and `severityOfCode()`.

Expand Down
4 changes: 2 additions & 2 deletions src/classes/category_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ export class CategoryConfig {
* All problems are collected in {@link CategoryConfig.diagnostics}.
*
* @param config - The category configuration to parse.
* @throws {@link DiagnosticError} if error-severity diagnostics exist.
* @throws {@link DiagnosticError} if a fatal (`COOKLANG_E9…`) diagnostic exists.
*/
parse(config: string): void {
this.source = config;
this.diagnostics = this._parseAndCollect(config).map((e) => e.diagnostic);
if (this.diagnostics.some((d) => d.severity === "error")) {
if (this.diagnostics.some((d) => d.code.startsWith("COOKLANG_E9"))) {
throw new DiagnosticError(this.diagnostics, config);
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/classes/pantry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,14 @@ export class Pantry {
*
* @param tomlContent - The TOML string to parse.
* @returns The parsed pantry items.
* @throws {@link DiagnosticError} if error-severity diagnostics exist.
* @throws {@link DiagnosticError} if a fatal (`COOKLANG_E9…`) diagnostic exists.
*/
parse(tomlContent: string): PantryItem[] {
this.source = tomlContent;
this.diagnostics = this._parseAndCollect(tomlContent).map(
(e) => e.diagnostic,
);
if (this.diagnostics.some((d) => d.severity === "error")) {
if (this.diagnostics.some((d) => d.code.startsWith("COOKLANG_E9"))) {
throw new DiagnosticError(this.diagnostics, tomlContent);
}
return this.items;
Expand Down
4 changes: 2 additions & 2 deletions src/classes/product_catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,13 @@ export class ProductCatalog {
*
* @param tomlContent - The TOML string to parse.
* @returns The parsed products.
* @throws {@link DiagnosticError} if error-severity diagnostics exist.
* @throws {@link DiagnosticError} if a fatal (`COOKLANG_E9…`) diagnostic exists.
*/
public parse(tomlContent: string): ProductOption[] {
const error = this.validate(tomlContent);
this.source = tomlContent;
this.diagnostics = error ? [error.diagnostic] : [];
if (this.diagnostics.some((d) => d.severity === "error")) {
if (this.diagnostics.some((d) => d.code.startsWith("COOKLANG_E9"))) {
throw new DiagnosticError(this.diagnostics, tomlContent);
}
return this.products;
Expand Down
4 changes: 2 additions & 2 deletions src/classes/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1616,7 +1616,7 @@ export class Recipe {
* Known problems are collected in {@link Recipe.diagnostics}.
*
* @param content - The recipe content to parse.
* @throws {@link DiagnosticError} if error-severity diagnostics exist.
* @throws {@link DiagnosticError} if a fatal (`COOKLANG_E9…`) diagnostic exists.
* @throws {@link ParseStoppedError} if parsing must stop immediately.
*/
parse(content: string): void {
Expand Down Expand Up @@ -2080,7 +2080,7 @@ export class Recipe {
(a, b) => (a.span?.start.offset ?? 0) - (b.span?.start.offset ?? 0),
);

if (this.diagnostics.some((d) => d.severity === "error")) {
if (this.diagnostics.some((d) => d.code.startsWith("COOKLANG_E9"))) {
throw new DiagnosticError(this.diagnostics, this._source);
}
}
Expand Down
9 changes: 0 additions & 9 deletions test/aisle_config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import { CategoryConfig } from "../src/classes/category_config";
import { DiagnosticError } from "../src/errors";

describe("parse_category_config", () => {
it("parses a simple config", () => {
Expand Down Expand Up @@ -108,14 +107,6 @@ describe("parse_category_config", () => {
});
});

it("throws when duplicate categories are found", () => {
const config = `
[produce]
[produce]
`;
expect(() => new CategoryConfig(config)).toThrow(DiagnosticError);
});

it("warns (without throwing) when duplicate ingredients are found", () => {
const config = `
[produce]
Expand Down
42 changes: 33 additions & 9 deletions test/config_diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ProductCatalog,
DiagnosticError,
} from "../src/index";
import { createDiagnostic } from "../src/diagnostics";
import { parseCollect } from "./mocks/parse_collect";

describe("CategoryConfig.parse()", () => {
Expand Down Expand Up @@ -32,14 +33,32 @@ describe("CategoryConfig.parse()", () => {
expect(r.output.categories.map((c) => c.name)).toEqual(["A", "B"]);
});

it("constructor throws like parse() for fatal diagnostics", () => {
expect(() => new CategoryConfig("[A]\n[A]")).toThrow(DiagnosticError);
it("constructor does not throw for non-fatal errors", () => {
const config = "[A]\n[A]";
let categoryConfig: CategoryConfig | undefined;
expect(() => (categoryConfig = new CategoryConfig(config))).not.toThrow();
expect(categoryConfig!.source).toBe(config);
expect(categoryConfig!.diagnostics).toHaveDiagnostic("COOKLANG_E0702");
});

it("constructor does not throw for warning-only diagnostics", () => {
expect(() => new CategoryConfig("a")).not.toThrow();
expect(() => new CategoryConfig("[A]\nx\ny|x")).not.toThrow();
});

it("throws if a fatal diagnostic is collected", () => {
const config = new CategoryConfig();
Reflect.set(config, "_parseAndCollect", () => [
{
diagnostic: createDiagnostic("COOKLANG_E9701", {
detail: "synthetic fatal diagnostic",
}),
},
]);

expect(() => config.parse("input")).toThrow(DiagnosticError);
expect(config.diagnostics).toHaveDiagnostic("COOKLANG_E9701");
});
});

describe("Pantry.parse()", () => {
Expand All @@ -56,13 +75,18 @@ describe("Pantry.parse()", () => {
});

it("omits items with invalid dates and keeps the others", () => {
const toml = `[fridge]\nmilk = { quantity = "1%l", expire = "not-a-date" }\neggs = "6"`;
const r = parseCollect(
new Pantry(undefined, { dateFormat: "DD.MM.YYYY" }),
toml,
);
expect(r.diagnostics.map((d) => d.code)).toEqual(["COOKLANG_E0701"]);
expect(r.output.items.map((i) => i.name)).toEqual(["eggs"]);
const toml =
`[fridge]\nmilk = { quantity = "1%l", expire = "not-a-date" }\n` +
`cream = { bought = "also-not-a-date" }\neggs = "6"`;
const pantry = new Pantry(undefined, { dateFormat: "DD.MM.YYYY" });

expect(() => pantry.parse(toml)).not.toThrow();
expect(pantry.source).toBe(toml);
expect(pantry.diagnostics.map((d) => d.code)).toEqual([
"COOKLANG_E0701",
"COOKLANG_E0701",
]);
expect(pantry.items.map((i) => i.name)).toEqual(["eggs"]);
});

it("constructor throws like parse()", () => {
Expand Down
45 changes: 24 additions & 21 deletions test/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,37 +303,40 @@ describe("Recipe.parse() diagnostics", () => {
});

describe("Recipe.parse() throwing", () => {
it("throws DiagnosticError when there are error-severity diagnostics", () => {
expect(() => new Recipe().parse("Add @flour{%two}")).toThrow(
DiagnosticError,
);
});

it("DiagnosticError carries diagnostics and source", () => {
it("throws DiagnosticError when there is a fatal diagnostic", () => {
let thrown: DiagnosticError | undefined;
try {
new Recipe().parse("Add @flour{%two}");
new Recipe().parse("Add @flour{1/0}");
} catch (e) {
if (e instanceof DiagnosticError) thrown = e;
}
expect(thrown).toBeDefined();
expect(thrown!.diagnostics).toHaveLength(1);
expect(thrown!.diagnostics[0]!.code).toBe("COOKLANG_E0101");
expect(thrown!.source).toBe("Add @flour{%two}");
expect(thrown!.diagnostics).toHaveDiagnostic("COOKLANG_E9402");
expect(thrown!.source).toBe("Add @flour{1/0}");
});

it("does not throw for a clean recipe", () => {
expect(() => new Recipe().parse("Add @flour{100%g}")).not.toThrow();
it("does not throw for non-fatal errors and keeps the partial result", () => {
const recipe = new Recipe();
expect(() => recipe.parse("Add @flour{%two} and @salt")).not.toThrow();
expect(recipe.diagnostics).toHaveDiagnostic("COOKLANG_E0101");
expect(recipe.ingredients.map((i) => i.name)).toEqual(["flour", "salt"]);
});

it("error message summarizes error count", () => {
let thrown: DiagnosticError | undefined;
try {
new Recipe().parse("@&a and @&b");
} catch (e) {
if (e instanceof DiagnosticError) thrown = e;
}
expect(thrown!.message).toContain("2 errors");
it("DiagnosticError carries diagnostics and source", () => {});

it("throws at the end of parsing when a fatal diagnostic was collected", () => {
const recipe = new Recipe();
Reflect.set(recipe, "_populateIngredientQuantities", () => {
recipe.diagnostics.push(createDiagnostic("COOKLANG_E9402", {}));
});

expect(() => recipe.parse("Add @flour{%two}")).toThrow(DiagnosticError);
expect(recipe.diagnostics).toHaveDiagnostic("COOKLANG_E0101");
expect(recipe.diagnostics).toHaveDiagnostic("COOKLANG_E9402");
});

it("does not throw for a clean recipe", () => {
expect(() => new Recipe().parse("Add @flour{100%g}")).not.toThrow();
});

it("DiagnosticError snapshots all diagnostics but counts only errors", () => {
Expand Down
54 changes: 22 additions & 32 deletions test/pantry.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest";
import { Pantry } from "../src/classes/pantry";
import { CategoryConfig } from "../src/classes/category_config";
import { DiagnosticError } from "../src/errors";
import type { PantryItem, FixedValue } from "../src/types";

const simplePantryToml = `
Expand Down Expand Up @@ -177,37 +176,28 @@ describe("Pantry", () => {
expect(pantry.items[0]!.expire).toEqual(new Date(2025, 5, 15));
});

it("should throw on invalid date input with explicit format", () => {
expect(
() =>
new Pantry(`[fridge]\nmilk = { expire = "2025-13-01" }`, {
dateFormat: "YYYY-MM-DD",
}),
).toThrow(DiagnosticError);
try {
new Pantry(`[fridge]\nmilk = { expire = "2025-13-01" }`, {
dateFormat: "YYYY-MM-DD",
});
} catch (error) {
expect(error).toBeInstanceOf(DiagnosticError);
expect((error as DiagnosticError).diagnostics[0]!.message).toContain(
"Invalid date",
);
}
});

it("should throw on non-numeric date parts", () => {
expect(
() => new Pantry(`[fridge]\nmilk = { expire = "abc.06.2025" }`),
).toThrow(DiagnosticError);
try {
new Pantry(`[fridge]\nmilk = { expire = "abc.06.2025" }`);
} catch (error) {
expect(error).toBeInstanceOf(DiagnosticError);
expect((error as DiagnosticError).diagnostics[0]!.message).toContain(
"non-numeric",
);
}
it("should report invalid date input with explicit format", () => {
const pantry = new Pantry(undefined, { dateFormat: "YYYY-MM-DD" });
expect(() =>
pantry.parse(`[fridge]\nmilk = { expire = "2025-13-01" }`),
).not.toThrow();
expect(pantry.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
"COOKLANG_E0701",
]);
expect(pantry.diagnostics[0]!.message).toContain("Invalid date");
expect(pantry.items).toEqual([]);
});

it("should report non-numeric date parts", () => {
const pantry = new Pantry();
expect(() =>
pantry.parse(`[fridge]\nmilk = { expire = "abc.06.2025" }`),
).not.toThrow();
expect(pantry.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
"COOKLANG_E0701",
]);
expect(pantry.diagnostics[0]!.message).toContain("non-numeric");
expect(pantry.items).toEqual([]);
});
});

Expand Down
Loading