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
2 changes: 1 addition & 1 deletion docs/.vitepress/diagnostics-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@

export interface DiagnosticPage {
code: string;
level: "error" | "warning";
level: "error (fatal)" |"error" | "warning";
title: string;
/** Markdown body, without frontmatter */
body: string;
}

const codesDir = join(__dirname, "../e/codes");

Check failure on line 12 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved

Check failure on line 12 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an error typed value

/**
* Reads the hand-written `docs/e/codes/<CODE>.md` pages. Frontmatter must
* contain `code`, `level` and `title`.
*/
export function readDiagnosticPages(): DiagnosticPage[] {
return readdirSync(codesDir)

Check failure on line 19 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved

Check failure on line 19 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved

Check failure on line 19 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved

Check failure on line 19 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved

Check failure on line 19 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe return of a value of type error
.filter((f) => f.endsWith(".md"))

Check failure on line 20 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of an `any` typed value

Check failure on line 20 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe return of a value of type `any`

Check failure on line 20 in docs/.vitepress/diagnostics-docs.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .filter on a type that cannot be resolved
.sort()
.map((file) => {
const raw = readFileSync(join(codesDir, file), "utf8");
Expand Down
31 changes: 31 additions & 0 deletions docs/e/codes/COOKLANG_E9403.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
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.
```
13 changes: 0 additions & 13 deletions docs/e/codes/COOKLANG_I0001.md

This file was deleted.

24 changes: 17 additions & 7 deletions docs/guide-diagnostics.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# Parse diagnostics

`Recipe.parse()` (and the `Recipe` constructor) collects known recipe problems
as diagnostics. Warnings are available on `recipe.diagnostics`. 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.
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.

```typescript
import {
DiagnosticError,
ParseStoppedError,
Recipe,
formatDiagnostics,
} from "@tmlmt/cooklang-parser";
Expand All @@ -17,6 +21,9 @@ try {
recipe.parse(source);
} catch (error) {
if (error instanceof DiagnosticError) {
if (error instanceof ParseStoppedError) {
console.error("Parsing stopped early");
}
console.error(formatDiagnostics(error.diagnostics, error.source));
} else {
throw error;
Expand Down Expand Up @@ -68,6 +75,7 @@ Parse-time diagnostics, reported by `Recipe.parse()`:
| [`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. |

Warnings never make `parse()` throw. Non-fatal errors (`E0…`) are collected
alongside everything else so that the whole recipe gets reported in one
Expand Down Expand Up @@ -102,7 +110,7 @@ array.

## Internal errors (`I…`)

Codes starting with `I` (e.g. `COOKLANG_I0001`, `COOKLANG_I0401`) indicate a
Codes starting with `I` (e.g. `COOKLANG_I0401`) indicate a
bug in the library itself, not a problem with your input. They behave like
fatal errors (always thrown immediately) but should never actually be
reachable through normal, documented usage of the public API. If you see
Expand All @@ -112,9 +120,11 @@ with a minimal reproduction.
## Recovery

Known token-level errors are collected and the parser continues with later
tokens and lines. Invalid tokens may be omitted from the partial recipe. Unknown
exceptions and internal invariant failures are not converted into diagnostics;
they still throw because they indicate unsupported input or a library defect.
tokens and lines. Invalid tokens may be omitted from the partial recipe. A
zero-denominator fraction is an exception: `ParseStoppedError` signals that no
later tokens or lines were parsed. Unknown exceptions and internal invariant
failures are not converted into diagnostics; they still throw because they
indicate unsupported input or a library defect.

## Migrating from earlier v3 alphas

Expand Down
69 changes: 64 additions & 5 deletions src/classes/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,14 @@ import Big from "big.js";
import { deepClone } from "../utils/general";
import {
InvalidQuantityFormat,
DivisionByZeroError,
ReferencedIngredientNotFoundError,
ReferencedCookwareNotFoundError,
ReferencedItemCannotBeRedefinedError,
NoTabAsIndentError,
BadIndentationError,
DiagnosticError,
ParseStoppedError,
} from "../errors";
import { createDiagnostic } from "../diagnostics";
import {
Expand Down Expand Up @@ -416,6 +418,34 @@ export class Recipe {
);
}

private _toParseStoppedError(
error: DivisionByZeroError,
span?: DiagnosticSourceSpan,
): ParseStoppedError {
const diagnostic = { ...error.diagnostic, span };
this.diagnostics.push(diagnostic);
return new ParseStoppedError(diagnostic, this._source, this.diagnostics);
}

private _parseQuantityValueOrStop(
quantity: string,
lineIdx: number,
column: number,
length: number,
): FixedValue | Range {
try {
return parseQuantityValue(quantity);
} catch (error) {
if (error instanceof DivisionByZeroError) {
throw this._toParseStoppedError(
error,
makeSpan(this._lineOffsets, lineIdx, column, length),
);
}
throw error;
}
}

private _parseQuantityRecursive(
quantityRaw: string,
lineIdx: number,
Expand All @@ -426,7 +456,12 @@ export class Recipe {
const quantities: QuantityWithExtendedUnit[] = [];
while (quantityMatch?.groups) {
const value = quantityMatch.groups.quantity
? parseQuantityValue(quantityMatch.groups.quantity)
? this._parseQuantityValueOrStop(
quantityMatch.groups.quantity,
lineIdx,
matchStart,
matchLength,
)
: undefined;
const unit = quantityMatch.groups.unit;
if (value) {
Expand Down Expand Up @@ -1553,6 +1588,7 @@ export class Recipe {
*
* @param content - The recipe content to parse.
* @throws {@link DiagnosticError} if error-severity diagnostics exist.
* @throws {@link ParseStoppedError} if parsing must stop immediately.
*/
parse(content: string): void {
this.reset();
Expand Down Expand Up @@ -1603,7 +1639,18 @@ export class Recipe {
if (unitSystem) Recipe.unitSystems.set(this, unitSystem);
} catch (e) {
// v8 ignore else -- @preserve: only indentation errors are thrown by the metadata parser
if (e instanceof NoTabAsIndentError) {
if (e instanceof DivisionByZeroError) {
const fractionOffset = content.indexOf(e.fraction);
const span =
fractionOffset === -1
? undefined
: makeSpanFromOffset(
this._lineOffsets,
fractionOffset,
e.fraction.length,
);
throw this._toParseStoppedError(e, span);
} else if (e instanceof NoTabAsIndentError) {
this.diagnostics.push(
createDiagnostic(
"COOKLANG_E0301",
Expand Down Expand Up @@ -1889,7 +1936,12 @@ export class Recipe {
flags.push("hidden");
}
const quantity = quantityRaw
? parseQuantityValue(quantityRaw)
? this._parseQuantityValueOrStop(
quantityRaw,
lineIdx,
sourceColumn,
sourceLength,
)
: undefined;
const newCookware: Cookware = {
name,
Expand Down Expand Up @@ -1943,7 +1995,12 @@ export class Recipe {
this.diagnostics.push(createDiagnostic("COOKLANG_E0102", {}, span));
} else {
const name = groups.timerName || undefined;
const duration = parseQuantityValue(durationStr);
const duration = this._parseQuantityValueOrStop(
durationStr,
lineIdx,
sourceColumn,
sourceLength,
);
const timerObj: Timer = {
name,
duration,
Expand Down Expand Up @@ -2011,7 +2068,9 @@ export class Recipe {
): void {
const span = makeSpan(this._lineOffsets, lineIdx, column, length);
// v8 ignore else -- @preserve: parse helpers only throw the error types handled above
if (e instanceof InvalidQuantityFormat) {
if (e instanceof DivisionByZeroError) {
throw this._toParseStoppedError(e, span);
} else if (e instanceof InvalidQuantityFormat) {
this.diagnostics.push(
createDiagnostic("COOKLANG_E0101", { value: e.value }, span),
);
Expand Down
9 changes: 4 additions & 5 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export const diagnosticCatalog = defineDiagnostics({
why: `One of both values is not a number, so a ratio cannot be computed`,
fix: `Use numeric quantities.`,
},
COOKLANG_E9403: {
why: `A fraction cannot have a zero denominator.`,
fix: `Use a nonzero denominator, e.g. 1/2.`,
},
// -- Domain 5: shopping list ----------------------------------------------
COOKLANG_E9501: {
why: (p: { detail: string }) => p.detail,
Expand Down Expand Up @@ -164,11 +168,6 @@ export const diagnosticCatalog = defineDiagnostics({
why: (p: { name: string }) => `Duplicate category found: ${p.name}`,
fix: `Declare each category only once.`,
},
// -- Domain 0: generic/internal -------------------------------------------
COOKLANG_I0001: {
why: (p: { detail: string }) => p.detail,
fix: `This is a bug in the library. Please report it with a reproduction.`,
},
},
});

Expand Down
47 changes: 35 additions & 12 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,27 @@ export class DiagnosticError extends Error {
}
}

/**
* Thrown when parsing must stop immediately after encountering a diagnostic.
* @category Errors
*/
export class ParseStoppedError extends DiagnosticError {
/** The diagnostic that caused parsing to stop. */
readonly diagnostic: Diagnostic;
/** Indicates that the parser stopped before processing the full source. */
readonly stoppedEarly = true;

constructor(
diagnostic: Diagnostic,
source: string,
diagnostics: Diagnostic[] = [diagnostic],
) {
super(diagnostics, source, diagnostic.message);
this.name = "ParseStoppedError";
this.diagnostic = diagnostic;
}
}

/**
* Base class of errors that describe exactly one problem.
* @category Errors
Expand All @@ -58,18 +79,6 @@ export class SingleDiagnosticError extends DiagnosticError {
}
}

/**
* Thrown when an internal invariant is violated — this indicates a bug in
* the library, not a problem with user input.
* @category Errors
*/
export class InternalError extends SingleDiagnosticError {
constructor(message: string) {
super(createDiagnostic("COOKLANG_I0001", { detail: message }));
this.name = "InternalError";
}
}

// ---------------------------------------------------------------------------
// Parse-time typed errors (used by parser_helpers; caught in recipe.ts)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -144,6 +153,20 @@ export class InvalidQuantityFormat extends SingleDiagnosticError {
}
}

/**
* Thrown when a fraction has a zero denominator.
* @category Errors
*/
export class DivisionByZeroError extends SingleDiagnosticError {
readonly fraction: string;

constructor(fraction: string) {
super(createDiagnostic("COOKLANG_E9403", {}));
this.name = "DivisionByZeroError";
this.fraction = fraction;
}
}

/**
* Thrown when tabs are used to indent a metadata block instead of spaces.
* @category Errors
Expand Down
4 changes: 2 additions & 2 deletions src/quantities/numeric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
Range,
UnitDefinition,
} from "../types";
import { InternalError } from "../errors";
import { DivisionByZeroError } from "../errors";

/** Default allowed denominators for fraction approximation */
export const DEFAULT_DENOMINATORS = [2, 3, 4];
Expand All @@ -24,7 +24,7 @@ export function simplifyFraction(
den: number,
): DecimalValue | FractionValue {
if (den === 0) {
throw new InternalError("Denominator cannot be zero.");
throw new DivisionByZeroError(`${num}/${den}`);
}

const commonDivisor = gcd(Math.abs(num), Math.abs(den));
Expand Down
5 changes: 5 additions & 0 deletions src/utils/parser_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { addQuantityValues } from "../quantities/mutations";
import { getNumericValue } from "../quantities/numeric";
import {
CannotAddTextValueError,
DivisionByZeroError,
InvalidQuantityFormat,
NoTabAsIndentError,
BadIndentationError,
Expand Down Expand Up @@ -265,6 +266,10 @@ export const parseFixedValue = (
const num = Number(parts[0]);
const den = Number(parts[1]);

if (den === 0) {
throw new DivisionByZeroError(s);
}

return { type: "fraction", num, den };
}

Expand Down
Loading
Loading