Skip to content

@gram-lang/parser

Turns .gram source text into a plain-object Abstract Syntax Tree (AST). This is the only stage of the pipeline that can fail on malformed input — every other package trusts that if it received an AST, it’s structurally valid.

function getAST(input: string): RecipeAST

Parses a .gram source string and returns the root RecipeAST node. Throws a GramParseError on invalid syntax.

import { getAST } from '@gram-lang/parser';

const ast = getAST(`
---
title: 'Crepes'
---

## Batter

Mix @flour{200g} and @milk{200ml}.
`);
class GramParseError extends Error {
  readonly offset: number;
  readonly expected: string;
}

Thrown by getAST on a syntax error.

FieldDescription
messageohm-js’s human-readable prose (source excerpt included) — safe to display directly.
offsetPlain character offset into input where the failure occurred.
expectedDescription of what the parser expected at that offset.

offset and expected are the portable, structured parts of the failure — useful for editor integrations (squiggly underlines, quick-fixes) that don’t want to parse ohm’s prose.

Every node has a type: ASTNodeType discriminant and an optional loc: { start, end } (character offsets into the source, present on most but not all node types — see the interfaces below).

Value
Recipe
Section
Step
Comment
Text
IntermediateDecl
RelativeQuantity
TextQuantity
Quantity
Ingredient
Composite
Cookware
Reference
Timer
Temperature
Alternative
ImportDecl
interface RecipeAST {
  type: ASTNodeType.Recipe;
  meta: Meta;              // Parsed frontmatter (title, date, tags, densities, ...)
  imports: ImportDecl[];   // @use directives, always present (empty array with none) — never mixed into children
  children: (SectionAST | StepAST | CommentAST)[]; // Sections, or top-level steps/comments
}

interface ImportDecl {
  type: ASTNodeType.ImportDecl;
  specifier: string;              // e.g. "./bases/pate.gram"
  bindings: ImportBinding[];
  retroPlanning?: RetroPlanningAST | null; // e.g. from `@use "..." as &levain ~{-2d}`
}

interface ImportBinding {
  exported: string;    // name as exported by the module ("default" for the unbraced `as &name` form)
  local: string;        // name bound in the host recipe
  loc?: Location;
}

See Module Imports for the @use syntax itself, and @gram-lang/modules for resolving and composing an import graph into a single AST.


interface SectionAST {
  type: ASTNodeType.Section;
  title: string | null;
  retroPlanning?: RetroPlanningAST | null;
  intermediateDecl?: IntermediateDecl | null;
  children: (StepAST | CommentAST)[];
  loc?: Location;
}

interface RetroPlanningAST {
  raw: string;             // e.g. "-2h", exactly as written
  sign: 1 | -1;
  value: number | null;    // null if no number could be extracted (e.g. free text)
  unit: string | null;     // null if no unit token was found; otherwise the raw token
                           // as written (kitchen resolves/validates it against d/h/min)
}

For ## Puff Pastry ~{-2h}, retroPlanning is { raw: "-2h", sign: -1, value: 2, unit: "h" }. Free text like ~{the day before} still parses (the parser never throws on this — see Times & Scheduling for why), but produces { raw: "the day before", sign: 1, value: null, unit: null }; it’s @gram-lang/kitchen that flags this as invalid (MISSING_UNIT) when compiling.

interface StepAST {
  type: ASTNodeType.Step;
  action?: string | null;              // e.g. "Mix" from "[Mix] ..."
  children: (TextAST | IngredientAST | CookwareAST | TimerAST | TemperatureAST
    | ReferenceAST | AlternativeAST | IntermediateDecl | CommentAST)[];
  loc?: Location;
}

type Modifier = "?" | "-" | "*" | "&" | "="; // optional, hidden, baker's %, reference, fixed

interface IngredientAST {
  type: ASTNodeType.Ingredient;
  name: string;
  modifiers: Modifier[];               // Sigil modifiers present on the ingredient
  quantity: QuantityAST | RelativeQuantityAST | TextQuantityAST | null;
  alias?: string | null;
  preparation?: string | null;
  composite?: CompositeAST | null;     // set for "<@parent" composite ingredients
  loc?: Location;
}

interface QuantityAST {
  type: ASTNodeType.Quantity;
  value?: QuantityValueAST;            // Discriminated union: SingleQuantityAST | FractionQuantityAST | RangeQuantityAST
  unit?: string | null;
  fixed: boolean;                      // true for the "@=" (never-scale) modifier
}

interface RelativeQuantityAST {
  type: ASTNodeType.RelativeQuantity;
  percent: number;
  target: string;
  referenceType: "variable" | "ingredient"; // "&name" vs "@name"
}

The remaining node interfaces (CookwareAST, ReferenceAST, TimerAST, TemperatureAST, CommentAST, AlternativeAST, IntermediateDecl, TextQuantityAST) follow the same pattern — see packages/parser/src/types.ts for the exhaustive list.

13 type guards are exported for safely narrowing ASTNode | StepAST | ... | null | undefined inputs without manual .type checks:

isIngredient, isCookware, isTimer, isTemperature, isReference, isIntermediateDecl, isAlternative, isComment, isStep, isSection, isQuantity, isTextQuantity, isRelativeQuantity.

import { getAST, isIngredient, isTimer } from '@gram-lang/parser';

const ast = getAST(source);

for (const section of ast.children) {
  for (const step of section.children) {
    if (step.type !== 'Step') continue;
    for (const node of step.children) {
      if (isIngredient(node)) {
        console.log('Ingredient:', node.name);
      } else if (isTimer(node)) {
        console.log('Timer:', node.name, node.quantity);
      }
    }
  }
}

Syntax highlighting: @gram-lang/parser/textmate

Section titled “Syntax highlighting: @gram-lang/parser/textmate”

A subpath export ships the TextMate grammar used by the VS Code extension and the docs’ own Shiki-powered code blocks:

import gramGrammar from '@gram-lang/parser/textmate' with { type: 'json' };

It resolves to a .tmLanguage.json file — pass it straight into any TextMate-grammar-compatible highlighter (Shiki, Monaco, VS Code).