diff --git a/ERROR_HANDLING_DESIGN.md b/ERROR_HANDLING_DESIGN.md new file mode 100644 index 0000000..b045008 --- /dev/null +++ b/ERROR_HANDLING_DESIGN.md @@ -0,0 +1,237 @@ +# Design plan — parse-time error diagnostics + +> Status: proposal (implementation deferred). +> Confirmed decisions: (1) **collect-all** diagnostics model (breaking change), +> (2) scope limited to **parse-time errors**, (3) library: **`nostics`**, +> (4) **severity split** (`error | warning`) from the start, +> (5) `parseOrThrow` throws a single **`CooklangParseError`** carrying +> `.diagnostics[]`, (6) docs URLs **stubbed** at `/e/` for now, +> (7) column accuracy via a **precomputed line-offset map**, +> (8) **all three** anonymous-error buckets addressed. + +## 1. Problem + +Today error handling in the parser is thin: + +- ~45 `throw` sites across 8 files; roughly half are typed classes in + [`src/errors.ts`](src/errors.ts), the rest are anonymous `new Error("...")`. +- **No positional information** — nothing knows _where_ in the source a problem is. +- **Fail-fast** — [`Recipe.parse()`](src/classes/recipe.ts) aborts on the first + `throw`, so a recipe with several mistakes only ever surfaces one. +- **No pretty-printing** — messages are plain strings. + +Goal: report **which** known errors occurred, **where** in the cooklang text +(line, character), and **pretty-print** them. + +Enabling fact: [`Recipe.parse()`](src/classes/recipe.ts) already iterates +line-by-line, and `currentLine.matchAll(tokensRegex)` exposes `match.index` +(the column) for every token — so **line + column are cheap to capture** at the +point most errors are raised; the plumbing simply isn't wired yet. + +## 2. Library evaluation + +| Option | Deps | Model | Code-frame w/ caret | Fit | +| ------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------- | +| **`nostics`** | 0 (core) | Central catalog of stable codes + typed params + `why`/`fix`/`docs`; `Diagnostic extends Error`; pluggable formatters (plain/ansi/json); collect **or** throw | No (renders `file:line:col`), but custom formatter API `(d) => string` lets us add one | **Best** | +| `@babel/code-frame` | CJS + transitive (`picocolors`, …) | Single-frame renderer only | Yes | Frame only, no catalog; heavy CJS chain | +| In-house | 0 | Whatever we build | Yes (we write it) | Full control, all maintenance on us | + +### Why `nostics` + +- **Zero runtime deps, ESM-native, TS-first** — matches this repo's ethos (4 + small deps, `sideEffects:false`, tree-shakeable, 100 % coverage target). Much + lighter than dragging in the CJS `@babel` chain. +- **Directly solves "proper management of known errors"**: replaces scattered + ad-hoc strings with one **catalog of stable codes** (`why`, `fix`, `docs`), + typed params inferred at each call site. +- `Diagnostic extends Error` → we can **collect** many (new model) _or_ still + `throw` one — no lock-in. +- Structured shape (`code`, `message`, `fix`, `docs`, `sources`, `cause`) is + great for both humans and tooling/agents. +- Maintained by the Nuxt/Vue core team (posva, antfu, danielroe), MIT. + +### The one gap and how we close it + +`nostics` `sources` are **strings** like `"recipe.cook:5:18"` — a location +_reference_, not a source line with a `^^^` caret. It pretty-prints the +_diagnostic_ (code / why / fix / sources / docs) but not a code-frame. + +We close the gap with a **small custom formatter** (nostics accepts any +`(diagnostic: Diagnostic) => string`). We attach the numeric span + the source +text to the diagnostic, and the formatter renders: + +``` +error[invalid-quantity]: Invalid quantity format + ┌─ recipe.cook:5:18 + │ +5 │ Crack the @eggs{3x} with @flour{100%g} + │ ^^ expected a number, range, or fraction + = fix: use a number, range (1-2), or fraction (1/2) + = see: https://cooklang-parser.tmlmt.com/e/invalid-quantity +``` + +Net recommendation: **`nostics` for the diagnostics backbone + a ~40-line +in-house code-frame formatter** for the caret view. + +## 3. Architecture + +### 3.1 Source spans (`src/types.ts`) + +```ts +export interface SourcePosition { + offset: number; + line: number; + column: number; +} +export interface SourceSpan { + start: SourcePosition; + end: SourcePosition; +} +``` + +A tiny helper turns `(lineIndex, columnIndex, length)` — which the parse loop +already has — into a `SourceSpan`, plus an `offset` computed from precomputed +line-start offsets of the original (pre-cleaned) content. + +> Note: `parse()` currently strips metadata/comments _before_ splitting into +> lines. To keep columns accurate we track spans against the **original** +> `content`; the cleaning step must preserve offsets (replace with equal-length +> blanks) or we compute a line-offset map up front. This is the main +> implementation subtlety. + +### 3.2 Diagnostics catalog (`src/diagnostics.ts`) + +One `defineDiagnostics({ docsBase, codes })` catalog with **stubbed** docs URLs +(pages authored later). Each code carries a `severity` (`error | warning`); +warnings are collected and parsing continues. Codes use explicit, readable names +(nostics allows any string): + +```ts +export const diagnostics = defineDiagnostics({ + docsBase: (code) => `https://cooklang-parser.tmlmt.com/e/${code}`, + codes: { + "invalid-quantity": { + why: (p: { value: string }) => `Invalid quantity format: "${p.value}"`, + fix: () => `Use a number, range (1-2), or fraction (1/2).`, + }, + "timer-missing-unit": { why: () => `Timer has a value but no unit.` }, + "referenced-ingredient-not-found": { + why: (p: { name: string }) => + `Referenced ingredient "${p.name}" was not defined before use.`, + fix: () => `Define it earlier, or drop the '&' to create a new one.`, + }, + "referenced-cookware-not-found": {/* … */}, + "referenced-item-redefined": {/* … */}, + "no-tab-indent": {/* … */}, + "bad-indentation": {/* … */}, + "invalid-date-format": {/* … */}, + }, +}); +``` + +Note: **no `reporters`** are configured — for a library we must not auto-print +to console. Calling a handle just builds and returns a `Diagnostic`; we collect +those and hand them back to the consumer. + +### 3.3 Collector + result (breaking change) + +New collect-all model. `parse()` accumulates diagnostics instead of throwing on +the first error: + +```ts +export interface ParseResult { + recipe: Recipe; + diagnostics: Diagnostic[]; // empty ⇒ clean parse +} +``` + +- Recoverable parse-time problems are **collected**; the parser continues with a + best-effort value (e.g. skip the bad token, keep the rest of the step). +- `Recipe.parseOrThrow(text)` throws a single **`CooklangParseError`** (new class + in [`src/errors.ts`](src/errors.ts)) exposing `.diagnostics: Diagnostic[]`; + its `message` summarizes counts and it only throws when at least one + `severity: "error"` diagnostic is present (warnings alone do not throw). +- `diagnostic.format(source)` / a top-level `formatDiagnostics(diags, source)` + produce the pretty code-frame output (§2 gap-closing formatter), coloring the + caret/label by `severity`. + +Spans are resolved through a **precomputed line-offset map** built once from the +**original** `content` (array of each line's start offset). `spanOf(lineIdx, +column, len)` uses it to produce accurate `{ offset, line, column }` regardless of +the metadata/comment stripping that `parse()` performs before splitting lines. + +### 3.4 Attaching spans + +Each call site passes `sources: ["recipe.cook:" + line + ":" + column]` plus a +private carrier for the numeric span + length so the custom formatter can draw +the caret. Example call site (replaces `throw new InvalidQuantityFormat(...)`): + +```ts +push( + diagnostics["invalid-quantity"]({ + value: quantityRaw, + sources: [loc(spanOf(lineIdx, matchStart, len))], + span: spanOf(lineIdx, matchStart, len), // carried for the code-frame formatter + }), +); +``` + +## 4. Parse-time throw sites in scope + +| Current site | New code | +| ----------------------------------------------------------------------------------------------- | --------------------------------- | +| `InvalidQuantityFormat` — [recipe.ts:361](src/classes/recipe.ts#L361), `parseArbitraryQuantity` | `invalid-quantity` | +| `"Timer missing unit"` — [recipe.ts:1652](src/classes/recipe.ts) | `timer-missing-unit` | +| Referenced ingredient not found — [parser_helpers.ts:116](src/utils/parser_helpers.ts) | `referenced-ingredient-not-found` | +| Referenced cookware not found — [parser_helpers.ts:173](src/utils/parser_helpers.ts) | `referenced-cookware-not-found` | +| `ReferencedItemCannotBeRedefinedError` (ingredient/cookware) — parser_helpers | `referenced-item-redefined` | +| `NoTabAsIndentError` — `parseNestedBlock` | `no-tab-indent` | +| `BadIndentationError` — `parseNestedBlock` | `bad-indentation` | +| `Invalid date format` — parser_helpers | `invalid-date-format` | + +Pure quantity-arithmetic errors (`CannotAddTextValueError`, +`IncompatibleUnitsError`) surfacing while merging a referenced cookware quantity +during parse are mapped to a parse diagnostic at that call site, keeping the +underlying classes intact for non-parse callers. + +### 4.1 Anonymous-error cleanup (all three buckets) + +| Bucket | Sites | Action | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **1. Parse-time** | `"Referenced ingredient not found"`, `"Referenced cookware not found"`, `"Invalid date format"` (parser_helpers), `"Timer missing unit"` (recipe.ts) | Become catalog diagnostic **codes** with spans (already in §4 table) | +| **2. Usage errors** | `"Duplicate category found"` ([category_config.ts](src/classes/category_config.ts)), `"Index out of bounds"` ×N ([shopping_list.ts](src/classes/shopping_list.ts)) | Promote to **named classes** in [`src/errors.ts`](src/errors.ts) (centralized messages, `instanceof`-checkable); remain ordinary `throw`s, not diagnostics | +| **3. Internal invariants** | `"Denominator cannot be zero"` ([numeric.ts](src/quantities/numeric.ts)) | Wrap in a single **`InternalError`** class (signals a bug, not user input) | + +## 5. Back-compat & migration + +- **Breaking**: `Recipe.parse()` no longer throws on recoverable parse errors; + it records them. `new Recipe(text)` should expose `recipe.diagnostics`. + Document in `CHANGELOG.md` (already `3.0.0-alpha`, so acceptable). +- Keep the existing error classes in [`src/errors.ts`](src/errors.ts) exported + for non-parse code paths; parse paths route through the catalog. +- Export new surface from [`src/index.ts`](src/index.ts): `diagnostics` (or a + narrower API), `SourceSpan`, `SourcePosition`, `ParseResult`, + `CooklangParseError`, `formatDiagnostics`, `Recipe.parseOrThrow`, and the new + named classes from bucket 2 + `InternalError`. + +## 6. Testing / coverage + +- Unit-test span math (line/column/offset) against multi-line fixtures incl. + `\r\n`, and content after metadata stripping. +- One test per catalog code: assert `code`, `message`, and `span`. +- Snapshot the code-frame formatter output (plain, no ANSI) in + `test/__snapshots__/`. +- Add a `test/diagnostics.test.ts` and extend `test/errors_text.test.ts`. +- Maintain 100 % coverage; `/* v8 ignore else -- @preserve */` only for + genuinely unreachable branches, documented. + +## 7. Resolved decisions + +1. **Severity** — ship with an `error | warning` split from the start; warnings + are collected and parsing continues. +2. **`parseOrThrow`** — throws one `CooklangParseError` exposing `.diagnostics[]` + (only when an `error`-severity diagnostic exists). +3. **Docs registry** — stub `/e/` URLs now via nostics `docsBase`; author + the pages in follow-up work. +4. **Column accuracy** — precompute a line-offset map from the original content. +5. **Anonymous errors** — handle all three buckets (see §4.1). diff --git a/package.json b/package.json index ed34ea3..621710e 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ }, "dependencies": { "big.js": "7.0.1", + "nostics": "^1.1.4", "smol-toml": "1.7.0", "unrun": "0.3.1", "yalps": "0.6.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c53219..f4e657d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: big.js: specifier: 7.0.1 version: 7.0.1 + nostics: + specifier: ^1.1.4 + version: 1.1.4 smol-toml: specifier: 1.7.0 version: 1.7.0 @@ -5546,6 +5549,9 @@ packages: nostics@0.2.0: resolution: {integrity: sha512-/WQpI46UMbqvy1okYb+V+9wW3J8/m6GJ33wm691n/tyi6YtJiZ6ssJjENAU7y4evfYrrgYN9HllKDzPvffil1w==} + nostics@1.1.4: + resolution: {integrity: sha512-U4FApICSLCQ0dYiN59pxUCEZC053palQXi57yLaNdMaL6TBY4C4q3qZrJKUGcor2dGv8EhEwt8y5KvU2bo2kFg==} + npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -12875,6 +12881,8 @@ snapshots: oxc-parser: 0.132.0 unplugin: 3.0.0 + nostics@1.1.4: {} + npm-run-path@5.3.0: dependencies: path-key: 4.0.0 diff --git a/src/classes/category_config.ts b/src/classes/category_config.ts index 947e2c1..26f8561 100644 --- a/src/classes/category_config.ts +++ b/src/classes/category_config.ts @@ -1,4 +1,9 @@ import type { Category, CategoryIngredient } from "../types"; +import { + DuplicateCategoryError, + IngredientWithoutCategoryError, + DuplicateIngredientAliasError, +} from "../errors"; /** * Parser for category configurations specified à-la-cooklang. @@ -70,7 +75,7 @@ export class CategoryConfig { .trim(); if (categoryNames.has(categoryName)) { - throw new Error(`Duplicate category found: ${categoryName}`); + throw new DuplicateCategoryError(categoryName); } categoryNames.add(categoryName); @@ -78,15 +83,13 @@ export class CategoryConfig { this.categories.push(currentCategory); } else { if (currentCategory === null) { - throw new Error( - `Ingredient found without a category: ${trimmedLine}`, - ); + throw new IngredientWithoutCategoryError(trimmedLine); } const aliases = trimmedLine.split("|").map((s) => s.trim()); for (const alias of aliases) { if (ingredientNames.has(alias)) { - throw new Error(`Duplicate ingredient/alias found: ${alias}`); + throw new DuplicateIngredientAliasError(alias); } ingredientNames.add(alias); } diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts index cf27cf1..dff84c3 100644 --- a/src/classes/recipe.ts +++ b/src/classes/recipe.ts @@ -77,7 +77,27 @@ import { resolveUnit, normalizeUnit } from "../units/definitions"; import { isUnitCompatibleWithSystem } from "../units/compatibility"; import Big from "big.js"; import { deepClone } from "../utils/general"; -import { InvalidQuantityFormat } from "../errors"; +import { + InvalidQuantityFormat, + ReferencedIngredientNotFoundError, + ReferencedCookwareNotFoundError, + ReferencedItemCannotBeRedefinedError, + NoTabAsIndentError, + BadIndentationError, + CooklangParseError, +} from "../errors"; +import { + invalidQuantityDiagnostic, + timerMissingUnitDiagnostic, + referencedIngredientNotFoundDiagnostic, + referencedCookwareNotFoundDiagnostic, + referencedItemRedefinedDiagnostic, + noTabIndentDiagnostic, + badIndentationDiagnostic, + metadataParseErrorDiagnostic, +} from "../diagnostics"; +import { buildLineOffsets, makeSpan } from "../utils/spans"; +import type { CooklangParseDiagnostic, ParseResult } from "../types"; /** * Recipe parser. @@ -143,6 +163,11 @@ export class Recipe { * The parsed arbitrary quantities. */ arbitraries: ArbitraryScalable[] = []; + /** + * Diagnostics collected during the last call to {@link Recipe.parse}. + * An empty array means the parse was clean. + */ + diagnostics: CooklangParseDiagnostic[] = []; /** * The parsed recipe servings. Used for scaling. Parsed from one of * {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves} @@ -168,6 +193,11 @@ export class Recipe { */ private static unitSystems = new WeakMap(); + /** Line offsets of the cleaned source — rebuilt on each parse() call. */ + private _lineOffsets: number[] = []; + /** The cleaned body source for the current parse — rebuilt on each parse() call. */ + private _cleanedSource: string = ""; + /** * External storage for item count (not a property on instances). * Used for giving ID numbers to items during parsing. @@ -281,6 +311,25 @@ export class Recipe { } } + /** + * Parses `content` and throws a {@link CooklangParseError} if any + * error-severity diagnostics were collected. + * + * Warnings alone do **not** cause a throw. + * + * @param content - The recipe content to parse. + * @returns The fully parsed Recipe. + * @throws {@link CooklangParseError} if error-severity diagnostics exist. + */ + static parseOrThrow(content: string): Recipe { + const recipe = new Recipe(content); + const errors = recipe.diagnostics.filter((d) => d.severity === "error"); + if (errors.length > 0) { + throw new CooklangParseError(errors, recipe._cleanedSource); + } + return recipe; + } + /** * Parses a matched arbitrary scalable quantity and adds it to the given array. * @private @@ -337,6 +386,9 @@ export class Recipe { private _parseQuantityRecursive( quantityRaw: string, + lineIdx: number, + matchStart: number, + matchLength: number, ): QuantityWithExtendedUnit[] { let quantityMatch = quantityRaw.match(quantityAlternativeRegex); const quantities: QuantityWithExtendedUnit[] = []; @@ -359,7 +411,13 @@ export class Recipe { } quantities.push(newQuantity); } else { - throw new InvalidQuantityFormat(quantityRaw); + this._collectParseError( + new InvalidQuantityFormat(quantityRaw), + lineIdx, + matchStart, + matchLength, + ); + return []; } quantityMatch = quantityMatch.groups.alternative ? quantityMatch.groups.alternative.match(quantityAlternativeRegex) @@ -371,6 +429,8 @@ export class Recipe { private _parseIngredientWithAlternativeRecursive( ingredientMatchString: string, items: Step["items"], + lineIdx: number, + matchStart: number, linkedVariants?: string[], ): void { const alternatives: IngredientAlternative[] = []; @@ -443,11 +503,22 @@ export class Recipe { newIngredient.extras = extras; } - const idxInList = findAndUpsertIngredient( - this.ingredients, - newIngredient, - reference, - ); + let idxInList: number; + try { + idxInList = findAndUpsertIngredient( + this.ingredients, + newIngredient, + reference, + ); + } catch (e) { + this._collectParseError( + e, + lineIdx, + matchStart, + ingredientMatchString.length, + ); + return; + } // 2. We build up the ingredient item // -- alternative quantities @@ -455,6 +526,9 @@ export class Recipe { if (groups.ingredientQuantity) { const parsedQuantities = this._parseQuantityRecursive( groups.ingredientQuantity, + lineIdx, + matchStart, + ingredientMatchString.length, ); const [primary, ...rest] = parsedQuantities; if (primary) { @@ -529,6 +603,8 @@ export class Recipe { private _parseIngredientWithGroupKey( ingredientMatchString: string, items: Step["items"], + lineIdx: number, + matchStart: number, linkedVariants?: string[], ): void { const match = ingredientMatchString.match(ingredientWithGroupKeyRegex); @@ -598,11 +674,22 @@ export class Recipe { newIngredient.extras = extras; } - const idxInList = findAndUpsertIngredient( - this.ingredients, - newIngredient, - reference, - ); + let idxInList: number; + try { + idxInList = findAndUpsertIngredient( + this.ingredients, + newIngredient, + reference, + ); + } catch (e) { + this._collectParseError( + e, + lineIdx, + matchStart, + ingredientMatchString.length, + ); + return; + } // 2. We build up the ingredient item // -- alternative quantities @@ -610,6 +697,9 @@ export class Recipe { if (groups.gIngredientQuantity) { const parsedQuantities = this._parseQuantityRecursive( groups.gIngredientQuantity, + lineIdx, + matchStart, + ingredientMatchString.length, ); const [primary, ...rest] = parsedQuantities; itemQuantity = { @@ -1415,25 +1505,52 @@ export class Recipe { /** * Parses a recipe from a string. + * + * Diagnostics are collected rather than thrown; check + * {@link Recipe.diagnostics} or inspect the returned {@link ParseResult}. + * Use {@link Recipe.parseOrThrow} if you prefer throwing on error-severity + * problems. + * * @param content - The recipe content to parse. + * @returns A {@link ParseResult} containing the recipe, diagnostics, and + * the cleaned source text for code-frame formatting. */ - parse(content: string) { - // Remove noise - const cleanContent = normalizeInputString( + parse(content: string): ParseResult { + // Reset diagnostic state + this.diagnostics = []; + + // Build cleaned source + line-offset map (for span computation) + this._cleanedSource = normalizeInputString( content .replace(metadataRegex, "") .replace(commentRegex, "") .replace(blockCommentRegex, ""), - ) - .trim() - .split(/\r\n?|\n/); - - // Metadata - const { metadata, servings, unitSystem }: MetadataExtract = - extractMetadata(content); - this.metadata = metadata; - this.servings = servings; - if (unitSystem) Recipe.unitSystems.set(this, unitSystem); + ).trim(); + this._lineOffsets = buildLineOffsets(this._cleanedSource); + + // Split into lines for the parse loop + const cleanContent = this._cleanedSource.split(/\r\n?|\n/); + + // Metadata (errors collected, not thrown) + try { + const { metadata, servings, unitSystem }: MetadataExtract = + extractMetadata(content); + this.metadata = metadata; + this.servings = servings; + if (unitSystem) Recipe.unitSystems.set(this, unitSystem); + } catch (e) { + if (e instanceof NoTabAsIndentError) { + this.diagnostics.push(noTabIndentDiagnostic()); + } else if (e instanceof BadIndentationError) { + this.diagnostics.push(badIndentationDiagnostic()); + } /* v8 ignore else -- @preserve: defensive catch-all for unexpected metadata errors */ else if ( + e instanceof Error + ) { + this.diagnostics.push( + metadataParseErrorDiagnostic({ detail: e.message }), + ); + } + } // Initializing utility variables and property bearers let blankLineBefore = true; @@ -1446,6 +1563,7 @@ export class Recipe { const discoveredVariants = new Set(); // We parse content line by line + let lineIdx = 0; for (const line of cleanContent) { // A blank line triggers flushing pending stuff if (line.trim().length === 0) { @@ -1590,12 +1708,20 @@ export class Recipe { this._parseIngredientWithAlternativeRecursive( match[0], items, + lineIdx, + idx, linkedVariants, ); } // Ingredient items part of a group of alternative ingredients else if (groups.gmIngredientName || groups.gsIngredientName) { - this._parseIngredientWithGroupKey(match[0], items, linkedVariants); + this._parseIngredientWithGroupKey( + match[0], + items, + lineIdx, + idx, + linkedVariants, + ); } // Cookware items else if (groups.mCookwareName || groups.sCookwareName) { @@ -1623,42 +1749,59 @@ export class Recipe { newCookware.flags = flags; } - // Add cookware in cookware list - const idxInList = findAndUpsertCookware( - this.cookware, - newCookware, - reference, - ); - - // Adding the item itself in the preparation - const newItem: CookwareItem = { - type: "cookware", - index: idxInList, - }; - if (quantity) { - newItem.quantity = quantity; + // Add cookware in cookware list (errors collected, not thrown) + try { + const idxInList = findAndUpsertCookware( + this.cookware, + newCookware, + reference, + ); + // Adding the item itself in the preparation + const newItem: CookwareItem = { + type: "cookware", + index: idxInList, + }; + if (quantity) { + newItem.quantity = quantity; + } + items.push(newItem); + } catch (e) { + this._collectParseError(e, lineIdx, idx, match[0].length); } - items.push(newItem); } // Arbitrary scalable quantities else if (groups.arbitraryQuantity) { - this._parseArbitraryScalable(groups, items); + try { + this._parseArbitraryScalable(groups, items); + } catch (e) { + this._collectParseError(e, lineIdx, idx, match[0].length); + } } // Then it's necessarily a timer which was matched else { const durationStr = groups.timerQuantity!.trim(); const unit = (groups.timerUnit || "").trim(); if (!unit) { - throw new Error("Timer missing unit"); + const span = makeSpan( + this._lineOffsets, + lineIdx, + idx, + match[0].length, + ); + this.diagnostics.push(timerMissingUnitDiagnostic(span)); + } else { + const name = groups.timerName || undefined; + const duration = parseQuantityValue(durationStr); + const timerObj: Timer = { + name, + duration, + unit, + }; + items.push({ + type: "timer", + index: this.timers.push(timerObj) - 1, + }); } - const name = groups.timerName || undefined; - const duration = parseQuantityValue(durationStr); - const timerObj: Timer = { - name, - duration, - unit, - }; - items.push({ type: "timer", index: this.timers.push(timerObj) - 1 }); } cursor = idx + match[0].length; @@ -1669,6 +1812,7 @@ export class Recipe { } blankLineBefore = false; + lineIdx++; } // End of content reached: pushing all temporarily saved elements @@ -1692,6 +1836,58 @@ export class Recipe { } this._populateIngredientQuantities(); + + return { + recipe: this, + diagnostics: this.diagnostics, + source: this._cleanedSource, + }; + } + + /** + * Converts a caught error from a parse-time helper into a diagnostic and + * pushes it onto {@link Recipe.diagnostics}. + */ + private _collectParseError( + e: unknown, + lineIdx: number, + column: number, + length: number, + ): void { + const span = makeSpan(this._lineOffsets, lineIdx, column, length); + if (e instanceof InvalidQuantityFormat) { + const valueMatch = e.message.match(/found in: (.+?)(?:\s*\(|$)/); + const value = valueMatch?.[1]?.trim() ?? ""; + this.diagnostics.push(invalidQuantityDiagnostic({ value }, span)); + } else if (e instanceof ReferencedIngredientNotFoundError) { + this.diagnostics.push( + referencedIngredientNotFoundDiagnostic( + { name: e.ingredientName }, + span, + ), + ); + } else if (e instanceof ReferencedCookwareNotFoundError) { + this.diagnostics.push( + referencedCookwareNotFoundDiagnostic({ name: e.cookwareName }, span), + ); + } else if (e instanceof ReferencedItemCannotBeRedefinedError) { + this.diagnostics.push( + referencedItemRedefinedDiagnostic( + { + itemType: e.itemType, + name: e.itemName, + flag: String(e.modifier), + }, + span, + ), + ); + } /* v8 ignore else -- @preserve: defensive catch-all; all known parse errors are typed above */ else if ( + e instanceof Error + ) { + this.diagnostics.push( + metadataParseErrorDiagnostic({ detail: e.message }), + ); + } } /** @@ -2243,6 +2439,9 @@ export class Recipe { newRecipe.timers = deepClone(this.timers); newRecipe.arbitraries = deepClone(this.arbitraries); newRecipe.servings = this.servings; + newRecipe.diagnostics = [...this.diagnostics]; + newRecipe._cleanedSource = this._cleanedSource; + newRecipe._lineOffsets = [...this._lineOffsets]; return newRecipe; } } diff --git a/src/classes/shopping_list.ts b/src/classes/shopping_list.ts index 1689a0b..231b19f 100644 --- a/src/classes/shopping_list.ts +++ b/src/classes/shopping_list.ts @@ -40,7 +40,12 @@ import { } from "../regex"; import { parseQuantityWithUnit } from "../utils/parser_helpers"; import { formatQuantity } from "../utils/render_helpers"; -import { NoTabAsIndentError, UnknownRecipePathError } from "../errors"; +import { + NoTabAsIndentError, + UnknownRecipePathError, + IndexOutOfBoundsError, + UnresolvedAlternativesError, +} from "../errors"; import { normalizeInputString } from "../utils/normalization"; /** @@ -572,7 +577,7 @@ export class ShoppingList { options.choices, ); if (errorMessage) { - throw new Error(errorMessage); + throw new UnresolvedAlternativesError(errorMessage); } if (!options.scaling) { @@ -658,7 +663,7 @@ export class ShoppingList { */ removeRecipe(index: number) { if (index < 0 || index >= this.recipes.length) { - throw new Error("Index out of bounds"); + throw new IndexOutOfBoundsError(); } this.recipes.splice(index, 1); this.calculateIngredients(); @@ -683,7 +688,7 @@ export class ShoppingList { */ removeManualItem(index: number): void { if (index < 0 || index >= this.manualItems.length) { - throw new Error("Index out of bounds"); + throw new IndexOutOfBoundsError(); } this.manualItems.splice(index, 1); this.calculateIngredients(); diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000..a4e8ce3 --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,180 @@ +/** + * Nostics-backed diagnostic catalog for parse-time errors. + * + * Each exported function creates a {@link CooklangParseDiagnostic} for a + * specific parse-time problem. They are the only place where diagnostic + * codes are defined; keep them here and nowhere else. + * + * No reporters are configured — this is a library, so nothing is ever + * auto-printed to the console. + */ +import { defineDiagnostics } from "nostics"; +import type { CooklangParseDiagnostic, SourceSpan } from "./types"; + +// --------------------------------------------------------------------------- +// Internal catalog (nostics provides typed params + docs URL generation) +// --------------------------------------------------------------------------- + +const _catalog = defineDiagnostics({ + docsBase: (code) => `https://cooklang-parser.tmlmt.com/e/${code}`, + codes: { + "invalid-quantity": { + why: (p: { value: string }) => + `Invalid quantity format: "${p.value}"`, + fix: `Use a number (3), range (1-2), or fraction (1/2).`, + }, + "timer-missing-unit": { + why: `Timer has a value but no unit.`, + fix: `Add a unit, e.g. ~{5%minutes}.`, + }, + "referenced-ingredient-not-found": { + why: (p: { name: string }) => + `Referenced ingredient "${p.name}" was not defined before use.`, + fix: `Define the ingredient earlier in the recipe, or drop the '&' to create a new entry.`, + }, + "referenced-cookware-not-found": { + why: (p: { name: string }) => + `Referenced cookware "${p.name}" was not defined before use.`, + fix: `Define the cookware earlier in the recipe, or drop the '&' to create a new entry.`, + }, + "referenced-item-redefined": { + why: (p: { itemType: string; name: string; flag: string }) => + `Referenced ${p.itemType} "${p.name}" cannot be redefined as "${p.flag}".`, + fix: `Remove the reference ('&') to create a new entry, or add the flag to the original definition.`, + }, + "no-tab-indent": { + why: `Tabs are not allowed for indentation in metadata blocks.`, + fix: `Replace tab characters with spaces.`, + }, + "bad-indentation": { + why: `Inconsistent indentation in a metadata block.`, + fix: `Use consistent space indentation (no mixing of levels).`, + }, + "metadata-parse-error": { + why: (p: { detail: string }) => p.detail, + fix: `Check the metadata section of your recipe.`, + }, + }, +}); + +// --------------------------------------------------------------------------- +// Internal helper +// --------------------------------------------------------------------------- + +type CatalogKey = keyof typeof _catalog; +type CatalogParams = { + "invalid-quantity": { value: string }; + "timer-missing-unit": Record; + "referenced-ingredient-not-found": { name: string }; + "referenced-cookware-not-found": { name: string }; + "referenced-item-redefined": { itemType: string; name: string; flag: string }; + "no-tab-indent": Record; + "bad-indentation": Record; + "metadata-parse-error": { detail: string }; +}; + +function make( + key: K, + params: CatalogParams[K], + severity: "error" | "warning", + span?: SourceSpan, +): CooklangParseDiagnostic { + const d = _catalog[key](params as never); + return { + code: d.name, + message: d.message, + fix: d.fix, + docs: d.docs, + severity, + span, + }; +} + +// --------------------------------------------------------------------------- +// Public factory functions +// --------------------------------------------------------------------------- + +/** + * Creates an `invalid-quantity` diagnostic. + * Emitted when a quantity string inside `{…}` cannot be parsed. + */ +export function invalidQuantityDiagnostic( + params: { value: string }, + span?: SourceSpan, +): CooklangParseDiagnostic { + return make("invalid-quantity", params, "error", span); +} + +/** + * Creates a `timer-missing-unit` diagnostic. + * Emitted when `~{value}` has a value but no unit. + */ +export function timerMissingUnitDiagnostic( + span?: SourceSpan, +): CooklangParseDiagnostic { + return make("timer-missing-unit", {}, "error", span); +} + +/** + * Creates a `referenced-ingredient-not-found` diagnostic. + * Emitted when `@&name` references an ingredient not yet declared. + */ +export function referencedIngredientNotFoundDiagnostic( + params: { name: string }, + span?: SourceSpan, +): CooklangParseDiagnostic { + return make("referenced-ingredient-not-found", params, "error", span); +} + +/** + * Creates a `referenced-cookware-not-found` diagnostic. + * Emitted when `#&name` references cookware not yet declared. + */ +export function referencedCookwareNotFoundDiagnostic( + params: { name: string }, + span?: SourceSpan, +): CooklangParseDiagnostic { + return make("referenced-cookware-not-found", params, "error", span); +} + +/** + * Creates a `referenced-item-redefined` diagnostic. + * Emitted when a reference (`&`) tries to change a flag on the original item. + */ +export function referencedItemRedefinedDiagnostic( + params: { itemType: string; name: string; flag: string }, + span?: SourceSpan, +): CooklangParseDiagnostic { + return make("referenced-item-redefined", params, "error", span); +} + +/** + * Creates a `no-tab-indent` diagnostic. + * Emitted when a metadata block contains tab indentation. + */ +export function noTabIndentDiagnostic( + span?: SourceSpan, +): CooklangParseDiagnostic { + return make("no-tab-indent", {}, "error", span); +} + +/** + * Creates a `bad-indentation` diagnostic. + * Emitted when a metadata block has inconsistent space indentation. + */ +export function badIndentationDiagnostic( + span?: SourceSpan, +): CooklangParseDiagnostic { + return make("bad-indentation", {}, "error", span); +} + +/** + * Creates a `metadata-parse-error` diagnostic. + * Emitted for other metadata errors (e.g. invalid date values). + */ +export function metadataParseErrorDiagnostic( + params: { detail: string }, + span?: SourceSpan, +): CooklangParseDiagnostic { + return make("metadata-parse-error", params, "error", span); +} diff --git a/src/errors.ts b/src/errors.ts index 028ac0d..bc23679 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,6 +1,100 @@ -import { IngredientFlag, CookwareFlag, NoProductMatchErrorCode } from "./types"; +import type { + CooklangParseDiagnostic, + IngredientFlag, + CookwareFlag, + NoProductMatchErrorCode, +} from "./types"; +// --------------------------------------------------------------------------- +// Parse-time aggregate error +// --------------------------------------------------------------------------- + +/** + * Thrown by {@link Recipe.parseOrThrow} when at least one error-severity + * diagnostic was collected during parsing. + * @category Errors + */ +export class CooklangParseError extends Error { + /** All diagnostics collected during the failed parse. */ + readonly diagnostics: CooklangParseDiagnostic[]; + /** + * The cleaned recipe body text used for parsing. + * Pass to {@link formatDiagnostic} for code-frame output. + */ + readonly source: string; + + constructor(diagnostics: CooklangParseDiagnostic[], source: string) { + const errorCount = diagnostics.filter((d) => d.severity === "error").length; + super( + `Recipe parsing failed with ${errorCount} error${errorCount !== 1 ? "s" : ""}.`, + ); + this.name = "CooklangParseError"; + this.diagnostics = diagnostics; + this.source = source; + } +} + +// --------------------------------------------------------------------------- +// Internal invariant error (bucket 3) +// --------------------------------------------------------------------------- + +/** + * 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 Error { + constructor(message: string) { + super(message); + this.name = "InternalError"; + } +} + +// --------------------------------------------------------------------------- +// Parse-time typed errors (used by parser_helpers; caught in recipe.ts) +// --------------------------------------------------------------------------- + +/** + * Thrown when a referenced ingredient (`&`) cannot be found in the + * ingredient list declared earlier in the recipe. + * @category Errors + */ +export class ReferencedIngredientNotFoundError extends Error { + readonly ingredientName: string; + constructor(ingredientName: string) { + super( + `Referenced ingredient "${ingredientName}" not found. A referenced ingredient must be declared before being referenced with '&'.`, + ); + this.name = "ReferencedIngredientNotFoundError"; + this.ingredientName = ingredientName; + } +} + +/** + * Thrown when a referenced cookware (`&`) cannot be found in the + * cookware list declared earlier in the recipe. + * @category Errors + */ +export class ReferencedCookwareNotFoundError extends Error { + readonly cookwareName: string; + constructor(cookwareName: string) { + super( + `Referenced cookware "${cookwareName}" not found. A referenced cookware must be declared before being referenced with '&'.`, + ); + this.name = "ReferencedCookwareNotFoundError"; + this.cookwareName = cookwareName; + } +} + +/** + * Thrown when a referenced ingredient or cookware (`&`) is redefined with a different flag than the original definition. + * @category Errors + */ export class ReferencedItemCannotBeRedefinedError extends Error { + readonly itemType: "ingredient" | "cookware"; + readonly itemName: string; + readonly modifier: IngredientFlag | CookwareFlag; + constructor( item_type: "ingredient" | "cookware", item_name: string, @@ -11,9 +105,16 @@ export class ReferencedItemCannotBeRedefinedError extends Error { You can either remove the reference to create a new ${item_type} defined as ${new_modifier} or add the ${new_modifier} flag to the original definition of the ${item_type}`, ); this.name = "ReferencedItemCannotBeRedefinedError"; + this.itemType = item_type; + this.itemName = item_name; + this.modifier = new_modifier; } } +// --------------------------------------------------------------------------- +// Shopping-cart errors +// --------------------------------------------------------------------------- + /** * Error thrown when trying to build a shopping cart without a product catalog * @category Errors @@ -57,6 +158,10 @@ export class NoProductMatchError extends Error { } } +/** + * Thrown when a product catalog is invalid (e.g. not a valid TOML file) + * @category Errors + */ export class InvalidProductCatalogFormat extends Error { constructor() { super("Invalid product catalog format."); @@ -64,6 +169,10 @@ export class InvalidProductCatalogFormat extends Error { } } +/** + * Thrown when a quantity with a text value is attempted to be added to another quantity + * @category Errors + */ export class CannotAddTextValueError extends Error { constructor() { super("Cannot add a quantity with a text value."); @@ -71,6 +180,10 @@ export class CannotAddTextValueError extends Error { } } +/** + * Thrown when two quantities with incompatible or unknown units are attempted to be added together + * @category Errors + */ export class IncompatibleUnitsError extends Error { constructor(unit1: string, unit2: string) { super( @@ -80,6 +193,10 @@ export class IncompatibleUnitsError extends Error { } } +/** + * Thrown when a quantity is found to be in an invalid format + * @category Errors + */ export class InvalidQuantityFormat extends Error { constructor(value: string, extra?: string) { super( @@ -89,6 +206,10 @@ export class InvalidQuantityFormat extends Error { } } +/** + * Thrown when tabs are used to indent a metadata block instead of spaces. + * @category Errors + */ export class NoTabAsIndentError extends Error { constructor() { super( @@ -98,9 +219,13 @@ export class NoTabAsIndentError extends Error { } } +/** + * Thrown when when a line in a nested block has inconsistent indentation (not the same as base or greater for children) + * @category Errors + */ export class BadIndentationError extends Error { constructor() { - super(`Bad identation of a nested block. Please use spaces only.`); + super(`Bad indentation of a nested block. Please use spaces only.`); this.name = "BadIndentationError"; } } @@ -117,3 +242,75 @@ export class UnknownRecipePathError extends Error { this.name = "UnknownRecipePathError"; } } + +// --------------------------------------------------------------------------- +// Category-config usage errors (bucket 2) +// --------------------------------------------------------------------------- + +/** + * Thrown when a category name is declared more than once in a CategoryConfig. + * @category Errors + */ +export class DuplicateCategoryError extends Error { + readonly categoryName: string; + constructor(categoryName: string) { + super(`Duplicate category found: ${categoryName}`); + this.name = "DuplicateCategoryError"; + this.categoryName = categoryName; + } +} + +/** + * Thrown when an ingredient line is encountered before any category header + * in a CategoryConfig. + * @category Errors + */ +export class IngredientWithoutCategoryError extends Error { + readonly ingredientLine: string; + constructor(ingredientLine: string) { + super(`Ingredient found without a category: ${ingredientLine}`); + this.name = "IngredientWithoutCategoryError"; + this.ingredientLine = ingredientLine; + } +} + +/** + * Thrown when the same ingredient name or alias appears more than once in a + * CategoryConfig. + * @category Errors + */ +export class DuplicateIngredientAliasError extends Error { + readonly alias: string; + constructor(alias: string) { + super(`Duplicate ingredient/alias found: ${alias}`); + this.name = "DuplicateIngredientAliasError"; + this.alias = alias; + } +} + +// --------------------------------------------------------------------------- +// Shopping-list usage errors (bucket 2) +// --------------------------------------------------------------------------- + +/** + * Thrown when an index is out of bounds in a ShoppingList operation. + * @category Errors + */ +export class IndexOutOfBoundsError extends Error { + constructor() { + super("Index out of bounds"); + this.name = "IndexOutOfBoundsError"; + } +} + +/** + * Thrown when a recipe is added to a ShoppingList that has unresolved + * ingredient alternatives (no choices provided for them). + * @category Errors + */ +export class UnresolvedAlternativesError extends Error { + constructor(detail: string) { + super(detail); + this.name = "UnresolvedAlternativesError"; + } +} diff --git a/src/index.ts b/src/index.ts index 978458c..c23be0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -166,6 +166,10 @@ import type { GetIngredientQuantitiesOptions, RawQuantityGroup, ShoppingListRecipeRef, + SourcePosition, + SourceSpan, + CooklangParseDiagnostic, + ParseResult, } from "./types"; export { @@ -256,18 +260,63 @@ export { GetIngredientQuantitiesOptions, RawQuantityGroup, ShoppingListRecipeRef, + SourcePosition, + SourceSpan, + CooklangParseDiagnostic, + ParseResult, }; // Errors import { + CooklangParseError, + InternalError, + ReferencedIngredientNotFoundError, + ReferencedCookwareNotFoundError, + ReferencedItemCannotBeRedefinedError, NoProductCatalogForCartError, NoShoppingListForCartError, UnknownRecipePathError, + DuplicateCategoryError, + IngredientWithoutCategoryError, + DuplicateIngredientAliasError, + IndexOutOfBoundsError, + UnresolvedAlternativesError, + InvalidProductCatalogFormat, + CannotAddTextValueError, + IncompatibleUnitsError, + InvalidQuantityFormat, + NoTabAsIndentError, + BadIndentationError, } from "./errors"; export { + CooklangParseError, + InternalError, + ReferencedIngredientNotFoundError, + ReferencedCookwareNotFoundError, + ReferencedItemCannotBeRedefinedError, NoProductCatalogForCartError, NoShoppingListForCartError, UnknownRecipePathError, + DuplicateCategoryError, + IngredientWithoutCategoryError, + DuplicateIngredientAliasError, + IndexOutOfBoundsError, + UnresolvedAlternativesError, + InvalidProductCatalogFormat, + CannotAddTextValueError, + IncompatibleUnitsError, + InvalidQuantityFormat, + NoTabAsIndentError, + BadIndentationError, }; + +// Diagnostics formatters + +import { + formatDiagnostic, + formatDiagnostics, +} from "./utils/code_frame"; + +export { formatDiagnostic, formatDiagnostics }; diff --git a/src/quantities/numeric.ts b/src/quantities/numeric.ts index 32989e0..77a8202 100644 --- a/src/quantities/numeric.ts +++ b/src/quantities/numeric.ts @@ -6,6 +6,7 @@ import type { Range, UnitDefinition, } from "../types"; +import { InternalError } from "../errors"; /** Default allowed denominators for fraction approximation */ export const DEFAULT_DENOMINATORS = [2, 3, 4]; @@ -23,7 +24,7 @@ export function simplifyFraction( den: number, ): DecimalValue | FractionValue { if (den === 0) { - throw new Error("Denominator cannot be zero."); + throw new InternalError("Denominator cannot be zero."); } const commonDivisor = gcd(Math.abs(num), Math.abs(den)); diff --git a/src/types.ts b/src/types.ts index d7fa2a2..19ee404 100644 --- a/src/types.ts +++ b/src/types.ts @@ -536,8 +536,7 @@ export interface RawQuantityGroup { flags?: IngredientFlag[]; /** The raw, unprocessed quantities for this ingredient across all its mentions. */ quantities: ( - | QuantityWithExtendedUnit - | FlatOrGroup + QuantityWithExtendedUnit | FlatOrGroup )[]; } @@ -628,11 +627,7 @@ export interface ArbitraryScalableItem { * @category Types */ export type StepItem = - | TextItem - | IngredientItem - | CookwareItem - | TimerItem - | ArbitraryScalableItem; + TextItem | IngredientItem | CookwareItem | TimerItem | ArbitraryScalableItem; /** * Represents a step in a recipe. @@ -1096,9 +1091,7 @@ export interface QuantityWithUnitDef extends QuantityBase { * @category Types */ export type QuantityWithUnitLike = - | QuantityWithPlainUnit - | QuantityWithExtendedUnit - | QuantityWithUnitDef; + QuantityWithPlainUnit | QuantityWithExtendedUnit | QuantityWithUnitDef; /** * Maps equivalent unit name → (primary unit name → ratio). @@ -1144,33 +1137,93 @@ export interface MaybeNestedAndGroup { * @category Types */ export type FlatGroup = - | FlatAndGroup - | FlatOrGroup; + FlatAndGroup | FlatOrGroup; /** * Represents any group type that may include nested groups * @category Types */ export type MaybeNestedGroup = - | MaybeNestedAndGroup - | MaybeNestedOrGroup; + MaybeNestedAndGroup | MaybeNestedOrGroup; /** * Represents any group type (flat or nested) * @category Types */ export type Group = - | MaybeNestedGroup - | FlatGroup; + MaybeNestedGroup | FlatGroup; /** * Represents any "or" group (flat or nested) * @category Types */ export type OrGroup = - | MaybeNestedOrGroup - | FlatOrGroup; + MaybeNestedOrGroup | FlatOrGroup; /** * Represents any "and" group (flat or nested) * @category Types */ export type AndGroup = - | MaybeNestedAndGroup - | FlatAndGroup; + MaybeNestedAndGroup | FlatAndGroup; + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +/** + * A position within a source string. + * @category Types + */ +export interface SourcePosition { + /** 0-based byte offset from the start of the source string. */ + offset: number; + /** 1-based line number. */ + line: number; + /** 1-based column number. */ + column: number; +} + +/** + * A half-open range `[start, end)` within a source string. + * @category Types + */ +export interface SourceSpan { + start: SourcePosition; + end: SourcePosition; +} + +/** + * A single diagnostic emitted during recipe parsing. + * @category Types + */ +export interface CooklangParseDiagnostic { + /** Stable code identifying the problem (e.g. `"invalid-quantity"`). */ + code: string; + /** Human-readable description of what went wrong. */ + message: string; + /** Actionable instructions on how to fix the problem, if available. */ + fix?: string; + /** Link to extended documentation, if available. */ + docs?: string; + /** Severity level. */ + severity: "error" | "warning"; + /** Location of the problem in the cleaned recipe source. */ + span?: SourceSpan; +} + +/** + * The result of a successful (or partial) recipe parse. + * @category Types + */ +export interface ParseResult { + /** The parsed recipe. */ + recipe: Recipe; + /** + * Diagnostics collected during parsing. + * An empty array means the parse was clean. + */ + diagnostics: CooklangParseDiagnostic[]; + /** + * The cleaned recipe body text (metadata and comments stripped, trimmed) + * that was fed to the parser. Span positions refer to this string. + * Pass to {@link formatDiagnostic} to render code-frame output. + */ + source: string; +} diff --git a/src/utils/code_frame.ts b/src/utils/code_frame.ts new file mode 100644 index 0000000..fdf2e60 --- /dev/null +++ b/src/utils/code_frame.ts @@ -0,0 +1,87 @@ +import type { CooklangParseDiagnostic } from "../types"; + +/** + * Renders a single {@link CooklangParseDiagnostic} as a human-readable string + * with a source code frame (caret pointer) when span information is available. + * + * @category Diagnostics + * @example + * ``` + * error[invalid-quantity]: Invalid quantity format: "%two" + * ┌─ recipe:1:12 + * │ + * 1 │ Add @flour{%two} + * │ ^^^^ + * = fix: Use a number (3), range (1-2), or fraction (1/2). + * = see: https://cooklang-parser.tmlmt.com/e/invalid-quantity + * ``` + * + * @param diagnostic - The diagnostic to render. + * @param source - The cleaned body text returned in {@link ParseResult.source} + * or stored on {@link CooklangParseError.source}. Span positions refer to + * this string. + * @param label - Optional label shown in the location marker (default `"recipe"`). + * @returns A multi-line plain-text string. + */ +export function formatDiagnostic( + diagnostic: CooklangParseDiagnostic, + source: string, + label = "recipe", +): string { + const { code, message, fix, docs, severity, span } = diagnostic; + const header = `${severity}[${code}]: ${message}`; + + if (!span) { + const parts: string[] = [header]; + if (fix) parts.push(` = fix: ${fix}`); + if (docs) parts.push(` = see: ${docs}`); + return parts.join("\n"); + } + + const { line, column } = span.start; + const endColumn = + span.end.line === span.start.line ? span.end.column : column + 1; + const caretLen = Math.max(1, endColumn - column); + + const sourceLines = source.split(/\r\n?|\n/); + const sourceLine = sourceLines[line - 1] ?? ""; + const lineNumStr = String(line); + // padLine replaces the digit(s) with spaces so caret and frame lines align + const padLine = lineNumStr + " "; + const padEmpty = " ".repeat(padLine.length); + + const parts: string[] = [ + header, + `${padEmpty}┌─ ${label}:${line}:${column}`, + `${padEmpty}│`, + `${padLine}│ ${sourceLine}`, + `${padEmpty}│ ${" ".repeat(column - 1)}${"^".repeat(caretLen)}`, + ]; + + if (fix) parts.push(`${padEmpty}= fix: ${fix}`); + if (docs) parts.push(`${padEmpty}= see: ${docs}`); + + return parts.join("\n"); +} + +/** + * Convenience wrapper that formats all diagnostics in `diagnostics` and joins + * them with a blank line separator. + * + * @category Diagnostics + * @param diagnostics - The diagnostics to render. + * @param source - The cleaned body text returned in {@link ParseResult.source} + * or stored on {@link CooklangParseError.source}. Span positions refer to + * this string. + * @param label - Optional label shown in the location marker (default `"recipe"`). + * @returns A multi-line plain-text string. + */ +export function formatDiagnostics( + diagnostics: CooklangParseDiagnostic[], + source: string, + label?: string, +): string { + return diagnostics + .map((d) => formatDiagnostic(d, source, label)) + .join("\n\n"); +} diff --git a/src/utils/parser_helpers.ts b/src/utils/parser_helpers.ts index b717f58..34e81b3 100644 --- a/src/utils/parser_helpers.ts +++ b/src/utils/parser_helpers.ts @@ -39,6 +39,8 @@ import { NoTabAsIndentError, BadIndentationError, ReferencedItemCannotBeRedefinedError, + ReferencedIngredientNotFoundError, + ReferencedCookwareNotFoundError, } from "../errors"; import { parseTimeToMinutes } from "./time"; import { normalizeInputString } from "./normalization"; @@ -114,9 +116,7 @@ export function findAndUpsertIngredient( ); if (indexFind === -1) { - throw new Error( - `Referenced ingredient "${name}" not found. A referenced ingredient must be declared before being referenced with '&'.`, - ); + throw new ReferencedIngredientNotFoundError(name); } // Ingredient already exists @@ -171,9 +171,7 @@ export function findAndUpsertCookware( ); if (index === -1) { - throw new Error( - `Referenced cookware "${name}" not found. A referenced cookware must be declared before being referenced with '&'.`, - ); + throw new ReferencedCookwareNotFoundError(name); } const existingCookware = cookware[index]!; diff --git a/src/utils/spans.ts b/src/utils/spans.ts new file mode 100644 index 0000000..6a20aab --- /dev/null +++ b/src/utils/spans.ts @@ -0,0 +1,55 @@ +import type { SourceSpan } from "../types"; + +/** + * Builds an array of byte offsets at which each line starts in the source. + * `lineOffsets[0]` is always `0`; `lineOffsets[n]` is the offset of line `n+1`. + * Handles `\n`, `\r\n`, and bare `\r` line endings. + */ +export function buildLineOffsets(source: string): number[] { + const offsets: number[] = [0]; + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + if (ch === "\r") { + const next = source[i + 1]; + offsets.push(next === "\n" ? i + 2 : i + 1); + if (next === "\n") i++; + } else if (ch === "\n") { + offsets.push(i + 1); + } + } + return offsets; +} + +/** + * Creates a {@link SourceSpan} from a (0-based) line index, (0-based) column, + * and token length, using a precomputed line-offset map. + * + * @param lineOffsets - Output of {@link buildLineOffsets}. + * @param lineIdx - 0-based line index within the source. + * @param column - 0-based character offset within the line. + * @param length - Number of characters in the token. + */ +export function makeSpan( + lineOffsets: number[], + lineIdx: number, + column: number, + length: number, +): SourceSpan { + const lineStart = lineOffsets[lineIdx] ?? 0; + const startOffset = lineStart + column; + return { + start: { offset: startOffset, line: lineIdx + 1, column: column + 1 }, + end: { + offset: startOffset + length, + line: lineIdx + 1, + column: column + 1 + length, + }, + }; +} + +/** + * Formats a span's start position as `"line:column"` (both 1-based). + */ +export function spanToLocString(span: SourceSpan): string { + return `${span.start.line}:${span.start.column}`; +} diff --git a/test/__snapshots__/utils_code_frame.test.ts.snap b/test/__snapshots__/utils_code_frame.test.ts.snap new file mode 100644 index 0000000..6013288 --- /dev/null +++ b/test/__snapshots__/utils_code_frame.test.ts.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`formatDiagnostic > renders a diagnostic with span as a code frame 1`] = ` +"error[invalid-quantity]: Invalid quantity format: "%two" + ┌─ recipe:1:11 + │ +1 │ Add @flour{%two} and @water{200%mL} + │ ^^^^^ + = fix: Use a number (3), range (1-2), or fraction (1/2). + = see: https://cooklang-parser.tmlmt.com/e/invalid-quantity" +`; + +exports[`formatDiagnostic > renders correctly for a multi-digit line number 1`] = ` +"error[invalid-quantity]: Invalid quantity format: "%bad" + ┌─ recipe:10:11 + │ +10 │ Add @flour{%bad} + │ ^^^^^" +`; diff --git a/test/aisle_config.test.ts b/test/aisle_config.test.ts index 1b6163f..0f2f4eb 100644 --- a/test/aisle_config.test.ts +++ b/test/aisle_config.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; import { CategoryConfig } from "../src/classes/category_config"; +import { + DuplicateCategoryError, + DuplicateIngredientAliasError, + IngredientWithoutCategoryError, +} from "../src/errors"; describe("parse_category_config", () => { it("parses a simple config", () => { @@ -107,47 +112,39 @@ describe("parse_category_config", () => { }); }); - it("throws an error for duplicate categories", () => { + it("throws a DuplicateCategoryError for duplicate categories", () => { const config = ` [produce] [produce] `; - expect(() => new CategoryConfig(config)).toThrow( - "Duplicate category found: produce", - ); + expect(() => new CategoryConfig(config)).toThrow(DuplicateCategoryError); }); - it("throws an error for duplicate ingredients", () => { + it("throws a DuplicateIngredientAliasError for duplicate ingredients", () => { const config = ` [produce] potatoes [dairy] potatoes `; - expect(() => new CategoryConfig(config)).toThrow( - "Duplicate ingredient/alias found: potatoes", - ); + expect(() => new CategoryConfig(config)).toThrow(DuplicateIngredientAliasError); }); - it("throws an error for duplicate aliases", () => { + it("throws a DuplicateIngredientAliasError for duplicate aliases", () => { const config = ` [produce] potato|spud [dairy] spud `; - expect(() => new CategoryConfig(config)).toThrow( - "Duplicate ingredient/alias found: spud", - ); + expect(() => new CategoryConfig(config)).toThrow(DuplicateIngredientAliasError); }); - it("throws an error for ingredients without a category", () => { + it("throws an IngredientWithoutCategoryError for ingredients without a category", () => { const config = ` potatoes [produce] `; - expect(() => new CategoryConfig(config)).toThrow( - "Ingredient found without a category: potatoes", - ); + expect(() => new CategoryConfig(config)).toThrow(IngredientWithoutCategoryError); }); }); diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts new file mode 100644 index 0000000..c3e579a --- /dev/null +++ b/test/diagnostics.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from "vitest"; +import { + invalidQuantityDiagnostic, + timerMissingUnitDiagnostic, + referencedIngredientNotFoundDiagnostic, + referencedCookwareNotFoundDiagnostic, + referencedItemRedefinedDiagnostic, + noTabIndentDiagnostic, + badIndentationDiagnostic, + metadataParseErrorDiagnostic, +} from "../src/diagnostics"; +import { Recipe, CooklangParseError } from "../src/index"; +import type { CooklangParseDiagnostic } from "../src/types"; + +describe("diagnostic factory functions", () => { + it("invalidQuantityDiagnostic returns correct shape", () => { + const d = invalidQuantityDiagnostic({ value: "%two" }); + expect(d).toMatchObject>({ + code: "invalid-quantity", + severity: "error", + }); + expect(d.message).toContain("%two"); + expect(d.fix).toBeDefined(); + expect(d.docs).toContain("invalid-quantity"); + expect(d.span).toBeUndefined(); + }); + + it("timerMissingUnitDiagnostic returns correct shape", () => { + const d = timerMissingUnitDiagnostic(); + expect(d).toMatchObject>({ + code: "timer-missing-unit", + severity: "error", + }); + expect(d.fix).toBeDefined(); + }); + + it("referencedIngredientNotFoundDiagnostic returns correct shape", () => { + const d = referencedIngredientNotFoundDiagnostic({ name: "flour" }); + expect(d).toMatchObject>({ + code: "referenced-ingredient-not-found", + severity: "error", + }); + expect(d.message).toContain("flour"); + }); + + it("referencedCookwareNotFoundDiagnostic returns correct shape", () => { + const d = referencedCookwareNotFoundDiagnostic({ name: "pan" }); + expect(d).toMatchObject>({ + code: "referenced-cookware-not-found", + severity: "error", + }); + expect(d.message).toContain("pan"); + }); + + it("referencedItemRedefinedDiagnostic returns correct shape", () => { + const d = referencedItemRedefinedDiagnostic({ + itemType: "ingredient", + name: "sugar", + flag: "hidden", + }); + expect(d).toMatchObject>({ + code: "referenced-item-redefined", + severity: "error", + }); + expect(d.message).toContain("sugar"); + expect(d.message).toContain("hidden"); + }); + + it("noTabIndentDiagnostic returns correct shape", () => { + const d = noTabIndentDiagnostic(); + expect(d).toMatchObject>({ + code: "no-tab-indent", + severity: "error", + }); + }); + + it("badIndentationDiagnostic returns correct shape", () => { + const d = badIndentationDiagnostic(); + expect(d).toMatchObject>({ + code: "bad-indentation", + severity: "error", + }); + }); + + it("metadataParseErrorDiagnostic returns correct shape", () => { + const d = metadataParseErrorDiagnostic({ + detail: "Invalid date value", + }); + expect(d).toMatchObject>({ + code: "metadata-parse-error", + severity: "error", + }); + expect(d.message).toBe("Invalid date value"); + }); + + it("attaches a span when provided", () => { + const span = { + start: { offset: 4, line: 1, column: 5 }, + end: { offset: 16, line: 1, column: 17 }, + }; + const d = invalidQuantityDiagnostic({ value: "%two" }, span); + expect(d.span).toEqual(span); + }); +}); + +describe("Recipe.parse() diagnostics", () => { + it("returns ParseResult with source and empty diagnostics for clean recipe", () => { + const result = new Recipe("Add @flour{100%g}").parse("Add @flour{100%g}"); + expect(result.diagnostics).toHaveLength(0); + expect(result.source).toBe("Add @flour{100%g}"); + expect(result.recipe).toBeInstanceOf(Recipe); + }); + + it("collects referenced-cookware-not-found diagnostic", () => { + const recipe = new Recipe("Use #&nonexistent-pan{}"); + expect(recipe.diagnostics).toHaveLength(1); + expect(recipe.diagnostics[0]).toMatchObject>( + { + code: "referenced-cookware-not-found", + severity: "error", + }, + ); + expect(recipe.diagnostics[0]!.message).toContain("nonexistent-pan"); + }); + + it("collects no-tab-indent diagnostic for tab in metadata", () => { + // A leading space followed by a tab is captured by nestedMetaVarRegex + // and triggers NoTabAsIndentError in parseNestedBlock + const content = "---\nsource:\n \t name: NYT\n---\nAdd @flour{100%g}"; + const recipe = new Recipe(content); + expect( + recipe.diagnostics.some((d) => d.code === "no-tab-indent"), + ).toBe(true); + }); + + it("collects bad-indentation diagnostic for inconsistent metadata indentation", () => { + // Two keys at different indentation levels at the same nesting depth + const content = "---\nsource:\n name: NYT\n url: http://x\n---\nAdd @flour"; + const recipe = new Recipe(content); + expect( + recipe.diagnostics.some((d) => d.code === "bad-indentation"), + ).toBe(true); + }); + + it("attaches accurate span to invalid-quantity diagnostic", () => { + const recipe = new Recipe("Add @flour{%two}"); + const diag = recipe.diagnostics[0]!; + expect(diag.span).toBeDefined(); + expect(diag.span!.start.line).toBe(1); + expect(diag.span!.start.column).toBeGreaterThan(1); + }); + + it("collects multiple diagnostics for multiple errors", () => { + const content = "Add @&flour and use #&pan and cook for ~{5}"; + const recipe = new Recipe(content); + expect(recipe.diagnostics.length).toBeGreaterThan(1); + }); + + it("parse() return value equals recipe.diagnostics", () => { + const recipe = new Recipe(); + const result = recipe.parse("Add @&flour{100%g}"); + expect(result.diagnostics).toBe(recipe.diagnostics); + }); +}); + +describe("Recipe.parseOrThrow()", () => { + it("throws CooklangParseError when there are error-severity diagnostics", () => { + expect(() => Recipe.parseOrThrow("Add @flour{%two}")).toThrow( + CooklangParseError, + ); + }); + + it("CooklangParseError carries diagnostics and source", () => { + let thrown: CooklangParseError | undefined; + try { + Recipe.parseOrThrow("Add @flour{%two}"); + } catch (e) { + if (e instanceof CooklangParseError) thrown = e; + } + expect(thrown).toBeDefined(); + expect(thrown!.diagnostics).toHaveLength(1); + expect(thrown!.diagnostics[0]!.code).toBe("invalid-quantity"); + expect(thrown!.source).toBe("Add @flour{%two}"); + }); + + it("does not throw for a clean recipe", () => { + expect(() => Recipe.parseOrThrow("Add @flour{100%g}")).not.toThrow(); + }); + + it("error message summarizes error count", () => { + let thrown: CooklangParseError | undefined; + try { + Recipe.parseOrThrow("@&a and @&b"); + } catch (e) { + if (e instanceof CooklangParseError) thrown = e; + } + expect(thrown!.message).toContain("2 errors"); + }); +}); diff --git a/test/pantry.test.ts b/test/pantry.test.ts index 47d3f40..e582e15 100644 --- a/test/pantry.test.ts +++ b/test/pantry.test.ts @@ -98,6 +98,26 @@ describe("Pantry", () => { expect(eggs.unit).toBeUndefined(); }); + it("should parse ingredient names with spaces using quoted TOML keys", () => { + const pantry = new Pantry( + `[pantry]\n"ground beef" = { quantity = "500%g" }`, + ); + expect(pantry.items).toHaveLength(1); + expect(pantry.items[0]).toMatchObject>({ + name: "ground beef", + location: "pantry", + }); + }); + + it("should find items with spaces by name", () => { + const pantry = new Pantry( + `[pantry]\n"ground beef" = { quantity = "500%g" }`, + ); + const item = pantry.findItem("ground beef"); + expect(item).toBeDefined(); + expect(item).toMatchObject>({ name: "ground beef" }); + }); + it("should parse via constructor", () => { const pantry = new Pantry(simplePantryToml); expect(pantry.items.length).toBeGreaterThan(0); diff --git a/test/parser_helpers.test.ts b/test/parser_helpers.test.ts index 95dcfd9..a0d3e70 100644 --- a/test/parser_helpers.test.ts +++ b/test/parser_helpers.test.ts @@ -37,6 +37,8 @@ import { NoTabAsIndentError, BadIndentationError, ReferencedItemCannotBeRedefinedError, + ReferencedIngredientNotFoundError, + ReferencedCookwareNotFoundError, } from "../src/errors"; describe("parseSimpleMetaVar", () => { @@ -906,10 +908,10 @@ describe("findAndUpsertCookware", () => { expect(cookware.length).toEqual(2); }); - it("should throw an error if a reference cookware does not exist", () => { + it("should throw a ReferencedCookwareNotFoundError if a reference cookware does not exist", () => { const newCookware: Cookware = { name: "unreferenced-cookware", flags: [] }; expect(() => findAndUpsertCookware([], newCookware, true)).toThrowError( - "Referenced cookware \"unreferenced-cookware\" not found. A referenced cookware must be declared before being referenced with '&'.", + ReferencedCookwareNotFoundError, ); }); @@ -1047,7 +1049,7 @@ describe("findAndUpsertIngredient", () => { expect(() => findAndUpsertIngredient(ingredients, newIngredient, true), ).toThrowError( - "Referenced ingredient \"unreferenced-ingredient\" not found. A referenced ingredient must be declared before being referenced with '&'.", + ReferencedIngredientNotFoundError, ); }); diff --git a/test/recipe_parsing.test.ts b/test/recipe_parsing.test.ts index 23c463d..a9da059 100644 --- a/test/recipe_parsing.test.ts +++ b/test/recipe_parsing.test.ts @@ -7,10 +7,6 @@ import { recipeWithInlineAlternatives, recipeWithSubgroupAlternatives, } from "./fixtures/recipes"; -import { - InvalidQuantityFormat, - ReferencedItemCannotBeRedefinedError, -} from "../src/errors"; import type { Cookware, Ingredient, @@ -70,9 +66,14 @@ describe("parse function", () => { ]); }); - it("throw an error if quantity has invalid format", () => { + it("collects invalid-quantity diagnostic for bad quantity format", () => { const recipe = "Add @flour{%two}"; - expect(() => new Recipe(recipe)).toThrowError(InvalidQuantityFormat); + const result = new Recipe(recipe); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + code: "invalid-quantity", + severity: "error", + }); }); it("extracts plain unquantified single-word ingredient correctly", () => { @@ -678,18 +679,24 @@ describe("parse function", () => { ]); }); - it("should throw an error if referenced ingredient does not exist", () => { + it("collects diagnostic when referenced ingredient does not exist", () => { const recipe = `Add @&flour{100%g}.`; - expect(() => new Recipe(recipe)).toThrow( - /Referenced ingredient "flour" not found/, - ); + const result = new Recipe(recipe); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + code: "referenced-ingredient-not-found", + severity: "error", + }); }); - it("should throw an error if referenced ingredient does not have the same flags", () => { + it("collects diagnostic when referenced ingredient is redefined with different flags", () => { const recipe = `Add @flour{100%g} and more @&-flour{100%g}.`; - expect(() => new Recipe(recipe)).toThrowError( - ReferencedItemCannotBeRedefinedError, - ); + const result = new Recipe(recipe); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + code: "referenced-item-redefined", + severity: "error", + }); }); it("simply add quantities to the referenced ingredients as separate ones if units are incompatible", () => { @@ -782,11 +789,14 @@ describe("parse function", () => { }); }); - it("should throw an error if referenced cookware does not have the same flags", () => { + it("collects diagnostic when referenced cookware is redefined with different flags", () => { const recipe = `Potentially use an #oven once, and potentially the same #&?oven again`; - expect(() => new Recipe(recipe)).toThrowError( - ReferencedItemCannotBeRedefinedError, - ); + const result = new Recipe(recipe); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + code: "referenced-item-redefined", + severity: "error", + }); }); }); @@ -799,9 +809,14 @@ describe("parse function", () => { }); // Note: timer name may be empty based on regex }); - it("throws error for missing timer unit", () => { + it("collects diagnostic for missing timer unit", () => { const badInput = "Cook for ~{15}"; - expect(() => new Recipe(badInput)).toThrow(/Timer missing unit/); + const result = new Recipe(badInput); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + code: "timer-missing-unit", + severity: "error", + }); }); it("normalizes full-width Cooklang tokens", () => { @@ -2473,9 +2488,14 @@ Add @water{1%tbsp} and some more @&water{100%mL} }); }); - it("throws an error if arbitrary scalable quantity has no numeric value", () => { + it("collects diagnostic if arbitrary scalable quantity has no numeric value", () => { const recipe = "{{calory-factor}}"; - expect(() => new Recipe(recipe)).toThrowError(InvalidQuantityFormat); + const result = new Recipe(recipe); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + code: "invalid-quantity", + severity: "error", + }); }); }); diff --git a/test/shopping_list.test.ts b/test/shopping_list.test.ts index 897c8e1..dd122ad 100644 --- a/test/shopping_list.test.ts +++ b/test/shopping_list.test.ts @@ -4,6 +4,10 @@ import { CategoryConfig } from "../src/classes/category_config"; import { Pantry } from "../src/classes/pantry"; import type { CategorizedIngredients, Ingredient } from "../src/types"; import { Recipe } from "../src/classes/recipe"; +import { + IndexOutOfBoundsError, + UnresolvedAlternativesError, +} from "../src/errors"; import { recipeForShoppingList1, recipeForShoppingList2, @@ -723,21 +727,21 @@ Add @potato{1%=large|1.5%cup} and @&potato{1%=small|0.5%cup} }); }); - it("should throw an error when adding a recipe with inline alternatives without choices", () => { + it("should throw an UnresolvedAlternativesError when adding a recipe with inline alternatives without choices", () => { const shoppingList = new ShoppingList(); // recipeAlt has inline alternatives (ingredient-item-0) expect(() => shoppingList.addRecipe(recipeAlt)).toThrowError( - /Recipe has unresolved alternatives.*ingredientItems.*ingredient-item-0/, + UnresolvedAlternativesError, ); }); - it("should throw an error when adding a recipe with grouped alternatives without choices", () => { + it("should throw an UnresolvedAlternativesError when adding a recipe with grouped alternatives without choices", () => { const shoppingList = new ShoppingList(); const recipeWithGroups = new Recipe(` Mix @|milk|milk{200%ml} or @|milk|almond milk{100%ml} `); expect(() => shoppingList.addRecipe(recipeWithGroups)).toThrowError( - /Recipe has unresolved alternatives.*ingredientGroups.*milk/, + UnresolvedAlternativesError, ); }); @@ -1168,10 +1172,10 @@ Sugar }, ]); }); - it("should throw an error when removing a recipe with an invalid index", () => { + it("should throw an IndexOutOfBoundsError when removing a recipe with an invalid index", () => { const shoppingList = new ShoppingList(); shoppingList.addRecipe(recipe1); - expect(() => shoppingList.removeRecipe(1)).toThrow("Index out of bounds"); + expect(() => shoppingList.removeRecipe(1)).toThrow(IndexOutOfBoundsError); }); }); @@ -1246,14 +1250,14 @@ Sugar expect(shoppingList.ingredients).toEqual([{ name: "olive oil" }]); }); - it("should throw an error when removing a manual item with an invalid index", () => { + it("should throw an IndexOutOfBoundsError when removing a manual item with an invalid index", () => { const shoppingList = new ShoppingList(); shoppingList.addManualItem({ name: "bread" }); expect(() => shoppingList.removeManualItem(1)).toThrow( - "Index out of bounds", + IndexOutOfBoundsError, ); expect(() => shoppingList.removeManualItem(-1)).toThrow( - "Index out of bounds", + IndexOutOfBoundsError, ); }); diff --git a/test/utils_code_frame.test.ts b/test/utils_code_frame.test.ts new file mode 100644 index 0000000..e4bfd5d --- /dev/null +++ b/test/utils_code_frame.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { formatDiagnostic, formatDiagnostics } from "../src/utils/code_frame"; +import type { CooklangParseDiagnostic } from "../src/types"; + +const source = "Add @flour{%two} and @water{200%mL}"; + +const diagWithSpan: CooklangParseDiagnostic = { + code: "invalid-quantity", + message: 'Invalid quantity format: "%two"', + fix: "Use a number (3), range (1-2), or fraction (1/2).", + docs: "https://cooklang-parser.tmlmt.com/e/invalid-quantity", + severity: "error", + span: { + start: { offset: 10, line: 1, column: 11 }, + end: { offset: 15, line: 1, column: 16 }, + }, +}; + +const diagWithoutSpan: CooklangParseDiagnostic = { + code: "timer-missing-unit", + message: "Timer has a value but no unit.", + fix: "Add a unit, e.g. ~{5%minutes}.", + severity: "error", +}; + +const warningDiag: CooklangParseDiagnostic = { + code: "some-warning", + message: "Something to watch out for", + severity: "warning", +}; + +describe("formatDiagnostic", () => { + it("renders a diagnostic with span as a code frame", () => { + const output = formatDiagnostic(diagWithSpan, source); + expect(output).toMatchSnapshot(); + }); + + it("includes the fix and docs lines", () => { + const output = formatDiagnostic(diagWithSpan, source); + expect(output).toContain("= fix:"); + expect(output).toContain("= see:"); + }); + + it("renders a diagnostic without span without code frame", () => { + const output = formatDiagnostic(diagWithoutSpan, source); + expect(output).not.toContain("│"); + expect(output).not.toContain("┌─"); + expect(output).toContain("error[timer-missing-unit]:"); + expect(output).toContain("= fix:"); + }); + + it("uses the custom label in the location marker", () => { + const output = formatDiagnostic(diagWithSpan, source, "my-recipe.cook"); + expect(output).toContain("my-recipe.cook:1:11"); + }); + + it("renders a warning severity correctly", () => { + const output = formatDiagnostic(warningDiag, source); + expect(output).toContain("warning[some-warning]:"); + }); + + it("renders a diagnostic without fix or docs with no extra lines", () => { + const output = formatDiagnostic(warningDiag, source); + expect(output).not.toContain("= fix:"); + expect(output).not.toContain("= see:"); + }); + + it("renders correctly for a multi-digit line number", () => { + const longSource = "\n".repeat(9) + "Add @flour{%bad}"; + const diagLine10: CooklangParseDiagnostic = { + code: "invalid-quantity", + message: 'Invalid quantity format: "%bad"', + severity: "error", + span: { + start: { offset: 10, line: 10, column: 11 }, + end: { offset: 15, line: 10, column: 16 }, + }, + }; + const output = formatDiagnostic(diagLine10, longSource); + expect(output).toContain("10 │"); + expect(output).toMatchSnapshot(); + }); +}); + +describe("formatDiagnostics", () => { + it("joins multiple diagnostics with blank lines", () => { + const output = formatDiagnostics([diagWithSpan, diagWithoutSpan], source); + expect(output).toContain("\n\n"); + expect(output).toContain("invalid-quantity"); + expect(output).toContain("timer-missing-unit"); + }); + + it("returns empty string for empty array", () => { + expect(formatDiagnostics([], source)).toBe(""); + }); +}); diff --git a/test/utils_numeric.test.ts b/test/utils_numeric.test.ts index e0d2d2e..1c251d2 100644 --- a/test/utils_numeric.test.ts +++ b/test/utils_numeric.test.ts @@ -13,13 +13,12 @@ import type { FixedValue, Range, } from "../src/types"; +import { InternalError } from "../src/errors"; import Big from "big.js"; describe("simplifyFraction", () => { - it("should throw an error when the denominator is zero", () => { - expect(() => simplifyFraction(1, 0)).toThrowError( - "Denominator cannot be zero.", - ); + it("should throw an InternalError when the denominator is zero", () => { + expect(() => simplifyFraction(1, 0)).toThrowError(InternalError); }); it("should simplify a fraction correctly", () => { diff --git a/test/utils_spans.test.ts b/test/utils_spans.test.ts new file mode 100644 index 0000000..486229b --- /dev/null +++ b/test/utils_spans.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { + buildLineOffsets, + makeSpan, + spanToLocString, +} from "../src/utils/spans"; +import type { SourceSpan } from "../src/types"; + +describe("buildLineOffsets", () => { + it("returns [0] for empty string", () => { + expect(buildLineOffsets("")).toEqual([0]); + }); + + it("handles a single line with no newline", () => { + expect(buildLineOffsets("hello")).toEqual([0]); + }); + + it("handles \\n line endings", () => { + expect(buildLineOffsets("line1\nline2\nline3")).toEqual([0, 6, 12]); + }); + + it("handles \\r\\n line endings", () => { + expect(buildLineOffsets("line1\r\nline2\r\nline3")).toEqual([0, 7, 14]); + }); + + it("handles bare \\r line endings", () => { + expect(buildLineOffsets("line1\rline2\rline3")).toEqual([0, 6, 12]); + }); + + it("handles trailing newline", () => { + expect(buildLineOffsets("line1\nline2\n")).toEqual([0, 6, 12]); + }); +}); + +describe("makeSpan", () => { + it("creates span for first line", () => { + const offsets = buildLineOffsets("Add @flour{1%g}"); + const span = makeSpan(offsets, 0, 4, 12); + expect(span).toEqual({ + start: { offset: 4, line: 1, column: 5 }, + end: { offset: 16, line: 1, column: 17 }, + }); + }); + + it("creates span for second line", () => { + const offsets = buildLineOffsets("first line\nsecond line"); + const span = makeSpan(offsets, 1, 0, 6); + expect(span).toEqual({ + start: { offset: 11, line: 2, column: 1 }, + end: { offset: 17, line: 2, column: 7 }, + }); + }); + + it("falls back to offset 0 for out-of-bounds lineIdx", () => { + const offsets = buildLineOffsets("hello"); + const span = makeSpan(offsets, 99, 0, 3); + expect(span.start.offset).toBe(0); + }); +}); + +describe("spanToLocString", () => { + it("formats span as line:column", () => { + const offsets = buildLineOffsets("Add @flour{1%g}"); + const span = makeSpan(offsets, 0, 4, 12); + expect(spanToLocString(span)).toBe("1:5"); + }); +});