diff --git a/docs/.vitepress/diagnostics-docs.ts b/docs/.vitepress/diagnostics-docs.ts index 33bb758b..f3091601 100644 --- a/docs/.vitepress/diagnostics-docs.ts +++ b/docs/.vitepress/diagnostics-docs.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; export interface DiagnosticPage { code: string; - level: "error" | "warning"; + level: "error (fatal)" |"error" | "warning"; title: string; /** Markdown body, without frontmatter */ body: string; diff --git a/docs/e/codes/COOKLANG_E9403.md b/docs/e/codes/COOKLANG_E9403.md new file mode 100644 index 00000000..5b8aac99 --- /dev/null +++ b/docs/e/codes/COOKLANG_E9403.md @@ -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. +``` diff --git a/docs/e/codes/COOKLANG_I0001.md b/docs/e/codes/COOKLANG_I0001.md deleted file mode 100644 index f0055ddb..00000000 --- a/docs/e/codes/COOKLANG_I0001.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -code: COOKLANG_I0001 -level: error(internal) -title: Internal error ---- - -## What this error means - -An internal invariant of the library was violated. This is a bug in `@tmlmt/cooklang-parser`, not a problem with your input. - -## How to fix it - -Please [open an issue](https://github.com/tmlmt/cooklang-parser/issues) with a minimal input that reproduces the error. diff --git a/docs/guide-diagnostics.md b/docs/guide-diagnostics.md index a090f13c..93c66a20 100644 --- a/docs/guide-diagnostics.md +++ b/docs/guide-diagnostics.md @@ -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"; @@ -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; @@ -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 @@ -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 @@ -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 diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts index a32d57ad..82bafe02 100644 --- a/src/classes/recipe.ts +++ b/src/classes/recipe.ts @@ -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 { @@ -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, @@ -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) { @@ -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(); @@ -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", @@ -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, @@ -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, @@ -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), ); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 50a9d7a4..ce543877 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -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, @@ -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.`, - }, }, }); diff --git a/src/errors.ts b/src/errors.ts index 79b46f03..a34f2c11 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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 @@ -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) // --------------------------------------------------------------------------- @@ -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 diff --git a/src/quantities/numeric.ts b/src/quantities/numeric.ts index 77a8202a..4e5eb81b 100644 --- a/src/quantities/numeric.ts +++ b/src/quantities/numeric.ts @@ -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]; @@ -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)); diff --git a/src/utils/parser_helpers.ts b/src/utils/parser_helpers.ts index fb0fe9d3..3cc4d4e2 100644 --- a/src/utils/parser_helpers.ts +++ b/src/utils/parser_helpers.ts @@ -35,6 +35,7 @@ import { addQuantityValues } from "../quantities/mutations"; import { getNumericValue } from "../quantities/numeric"; import { CannotAddTextValueError, + DivisionByZeroError, InvalidQuantityFormat, NoTabAsIndentError, BadIndentationError, @@ -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 }; } diff --git a/test/diagnostics_catalog.test.ts b/test/diagnostics_catalog.test.ts index dcb26aae..03c46ef0 100644 --- a/test/diagnostics_catalog.test.ts +++ b/test/diagnostics_catalog.test.ts @@ -8,7 +8,6 @@ import { ShoppingList, SingleDiagnosticError, DiagnosticError, - InternalError, diagnosticCatalog, severityOfCode, } from "../src/index"; @@ -26,7 +25,7 @@ describe("diagnostic catalog", () => { it("derives the severity from the code letter", () => { expect(severityOfCode("COOKLANG_E0101")).toBe("error"); expect(severityOfCode("COOKLANG_W0101")).toBe("warning"); - expect(severityOfCode("COOKLANG_I0001")).toBe("error"); + expect(severityOfCode("COOKLANG_I0401")).toBe("error"); }); it("has a documentation page for every code", () => { @@ -71,7 +70,6 @@ describe("source code", () => { describe("typed errors", () => { it("all extend SingleDiagnosticError with a code and diagnostic", () => { const instances = [ - new errors.InternalError("x"), new errors.RatioNotComputableError(), new errors.InvalidPantryError(), new errors.InvalidCategoryConfigError(), @@ -87,7 +85,6 @@ describe("typed errors", () => { expect(e.code).toBe(e.diagnostic.code); expect(e.diagnostic.fix).toBeDefined(); } - expect(new InternalError("boom").message).toBe("boom"); expect(new errors.NoProductMatchError("flour", "noQuantity").reason).toBe( "noQuantity", ); diff --git a/test/parser_helpers.test.ts b/test/parser_helpers.test.ts index 87903c63..ab44394d 100644 --- a/test/parser_helpers.test.ts +++ b/test/parser_helpers.test.ts @@ -39,6 +39,7 @@ import { ReferencedItemCannotBeRedefinedError, ReferencedIngredientNotFoundError, ReferencedCookwareNotFoundError, + DivisionByZeroError, } from "../src/errors"; describe("parseSimpleMetaVar", () => { @@ -1107,6 +1108,10 @@ describe("parseFixedValue", () => { }); }); + it("throws a division-by-zero error for a zero-denominator fraction", () => { + expect(() => parseFixedValue("1/0")).toThrow(DivisionByZeroError); + }); + it("parses decimal values as such", () => { expect(parseFixedValue("1.5")).toEqual({ type: "decimal", decimal: 1.5 }); expect(parseFixedValue("0.1")).toEqual({ type: "decimal", decimal: 0.1 }); diff --git a/test/recipe_parsing.test.ts b/test/recipe_parsing.test.ts index 48b8fcdf..cfd01ba4 100644 --- a/test/recipe_parsing.test.ts +++ b/test/recipe_parsing.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { Recipe } from "../src/classes/recipe"; +import { ParseStoppedError } from "../src/errors"; import { parseCollect } from "./mocks/parse_collect"; import { simpleRecipe, @@ -77,6 +78,55 @@ describe("parse function", () => { }); }); + it("stops immediately with COOKLANG_E1003 for a zero-denominator fraction", () => { + const source = "Add @flour{1/0}\nAdd @sugar{2}"; + const recipe = new Recipe(); + let thrown: unknown; + + try { + recipe.parse(source); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(ParseStoppedError); + expect(thrown).toMatchObject({ + stoppedEarly: true, + source, + diagnostics: [ + { + code: "COOKLANG_E9403", + severity: "error", + span: { + start: { offset: source.indexOf("@flour{1/0}") }, + end: { offset: source.indexOf("@flour{1/0}") + 11 }, + }, + }, + ], + }); + expect(recipe.diagnostics).toEqual( + (thrown as ParseStoppedError).diagnostics, + ); + expect(recipe.ingredients.map((ingredient) => ingredient.name)).toEqual([ + "flour", + ]); + }); + + it.each([ + "Use #pan{1/0}", + "Cook for ~{1/0%minutes}", + "Scale {{1/0}}", + "---\nyield: 1/0%kg\n---\nAdd @flour{1%kg}", + ])("stops immediately for zero-denominator quantity in %s", (source) => { + const recipe = new Recipe(); + + expect(() => recipe.parse(source)).toThrow(ParseStoppedError); + expect(recipe.diagnostics[0]).toMatchObject({ + code: "COOKLANG_E9403", + severity: "error", + }); + }); + it("extracts plain unquantified single-word ingredient correctly", () => { const result = new Recipe("@flour"); expect(result.ingredients).toEqual([ diff --git a/test/utils_numeric.test.ts b/test/utils_numeric.test.ts index 74d865de..22954cb9 100644 --- a/test/utils_numeric.test.ts +++ b/test/utils_numeric.test.ts @@ -13,12 +13,12 @@ import type { FixedValue, Range, } from "../src/types"; -import { InternalError } from "../src/errors"; +import { DivisionByZeroError } from "../src/errors"; import Big from "big.js"; describe("simplifyFraction", () => { - it("should throw an InternalError when the denominator is zero", () => { - expect(() => simplifyFraction(1, 0)).toThrow(InternalError); + it("should throw a DivisionByZeroError when the denominator is zero", () => { + expect(() => simplifyFraction(1, 0)).toThrow(DivisionByZeroError); }); it("should simplify a fraction correctly", () => {