Si alguna vez abriste un archivo de gramática de Yacc y te preguntaste por qué construir un lenguaje requiere aprender un segundo lenguaje, no estás solo. Los generadores de parsers son poderosos, pero para los DSLs pequeños que surgen naturalmente dentro de contextos acotados, casi siempre son excesivos.

Puedes escribir un parser en el mismo lenguaje que el resto de tu aplicación. Sin pasos de build, sin archivos de gramática, sin código generado que no puedas debuggear.

Los generadores de parsers resuelven un problema que probablemente no tienes

Los generadores de parsers como ANTLR, Bison o PEG.js te obligan a expresar tu gramática en un metalenguaje específico del dominio. Escribes un archivo .g4 o .y, ejecutas una herramienta, obtienes código fuente generado, lo importas y esperas que los mensajes de error tengan sentido cuando algo se rompe.

Para un lenguaje de programación completo, este trade-off vale la pena. Los parsers LR generados son rápidos, y la separación entre gramática e implementación es limpia.

Pero la mayoría de nosotros no estamos construyendo lenguajes de programación. Estamos construyendo filtros de query, formatos de config, rule engines o lenguajes de expresión diminutos que viven dentro de un solo bounded context. El overhead de un generador de parsers, un paso de build separado y una segunda sintaxis que aprender es fricción pura.

Los combinadores de parsers convierten el parsing en código ordinario

Los combinadores de parsers son funciones que devuelven parsers, y los parsers son funciones que consumen input y devuelven ya sea un valor parseado o un error. Compones parsers pequeños en parsers más grandes usando higher-order functions.

Un parser para el string "hello" es una función. Un parser para "hello" OR "world" es una función que intenta el primero, y si falla, intenta el segundo. Un parser para "hello" THEN "world" los ejecuta en secuencia y combina los resultados.

Esto significa que tu gramática es solo código. Lo debuggeas con un breakpoint, no con una herramienta de visualización de gramáticas.

Un parser aritmético funcional en 40 líneas de TypeScript

Aquí tienes un parser completo para un lenguaje de expresiones diminuto. Maneja enteros y suma entre paréntesis. Sin dependencies, sin archivos generados.

type Result<T> = 
  | { ok: true; value: T; pos: number }
  | { ok: false; error: string; pos: number };

type Parser<T> = (input: string, pos: number) => Result<T>;

// Primitives: match a literal string or a regex
const str = (expected: string): Parser<string> => (input, pos) => {
  const end = pos + expected.length;
  return input.slice(pos, end) === expected
    ? { ok: true, value: expected, pos: end }
    : { ok: false, error: `expected "${expected}"`, pos };
};

const regex = (re: RegExp): Parser<string> => (input, pos) => {
  const m = input.slice(pos).match(new RegExp(`^(?:${re.source})`));
  return m
    ? { ok: true, value: m[0], pos: pos + m[0].length }
    : { ok: false, error: `expected /${re.source}/`, pos };
};

// Combinators: sequence, choice, map, lazy
const map = <A, B>(p: Parser<A>, f: (a: A) => B): Parser<B> => (input, pos) => {
  const r = p(input, pos);
  return r.ok ? { ...r, value: f(r.value) } : r;
};

const seq = <T extends Parser<unknown>[]>(...ps: T): Parser<any[]> => (input, pos) => {
  const values: unknown[] = [];
  let curr = pos;
  for (const p of ps) {
    const r = p(input, curr);
    if (!r.ok) return r;
    values.push(r.value);
    curr = r.pos;
  }
  return { ok: true, value: values, pos: curr };
};

const or = <A, B>(a: Parser<A>, b: Parser<B>): Parser<A | B> => (input, pos) => {
  const r = a(input, pos);
  return r.ok ? r : b(input, pos);
};

const lazy = <T>(fn: () => Parser<T>): Parser<T> => (input, pos) => fn()(input, pos);

// Grammar: expr := number | "(" expr "+" expr ")"
const ws = regex(/\s*/);
const number = map(regex(/\d+/), n => parseInt(n, 10));

let expr: Parser<{ val: number }>;

expr = or(
  map(
    seq(str("("), ws, lazy(() => expr), ws, str("+"), ws, lazy(() => expr), str(")")),
    ([, , left, , , , right]) => ({ val: left.val + right.val })
  ),
  map(number, val => ({ val }))
);

// Run it
const result = expr("(1 + (2 + 3))", 0);
console.log(result.ok ? result.value : result.error);
// Output: { val: 6 }

Desglosemos lo que realmente sucede. str, regex y number son parsers primitivos. seq ejecuta parsers en orden. or prueba alternativas. map transforma el resultado. lazy es la única pieza sutil. Difiere la evaluación para que las gramáticas recursivas no exploten en tiempo de definición.

El type system rastrea lo que cada parser produce. Cuando el parsing falla, obtienes la posición exacta y el token esperado. Eso ya es mejor error reporting que la mayoría de los parsers generados ofrecen out of the box.

Por qué encaja particularmente bien con los bounded contexts

Los bounded contexts en Domain-Driven Design son intencionalmente pequeños. Un DSL que vive dentro de uno también debería ser pequeño. Necesita exactamente los constructos que le importan a ese contexto, y nada más.

Los combinadores de parsers escalan hacia abajo bellamente. Escribes el parser para el único query filter que tu dominio necesita, no para un lenguaje de query de propósito general. Agregas un nuevo constructo agregando una nueva función, no regenerando mil líneas de C.

El parser vive en el mismo repository, el mismo lenguaje y el mismo modelo mental que el resto de tu bounded context. Cuando el domain model cambia, el parser cambia con él. No hay archivo de gramática desfasándose en otro directorio.

Dónde los combinadores fallan

No son gratis. Los parsers de recursive descent, que es lo que los combinadores construyen bajo el capó, tienen dificultades con reglas left-recursive. Si escribes expr := expr + number | number, el parser se llama a sí mismo para siempre.

Arreglas esto reescribiendo reglas left-recursive en loops, o usando una library que maneje la left recursion por ti. Para DSLs pequeños, rara vez es un problema práctico.

El performance es la otra advertencia. Un parser de recursive descent afinado a mano o un parser LR generado le ganará a los combinadores en throughput puro. Para un archivo de config leído una vez al inicio, o una rule evaluada por request, la diferencia es microsegundos. Mide antes de asumir que importa.

Cómo empezar a construir el tuyo

Empieza con la gramática más pequeña posible. Parsea un constructo, pruébalo, luego compón.

Si escribes TypeScript, libraries como parsimmon, arcsecond o chevrotain te dan combinadores listos para producción con mejores mensajes de error y manejo de left recursion que la implementación scratch de arriba. Rust tiene nom. Haskell tiene Parsec. Python tiene parsy.

El patrón es el mismo en todas partes: primitives, sequence, choice, repetition, transformation. Apréndelo una vez, aplícalo en cualquier lenguaje.

FAQ

¿Qué es un parser combinator?

Un parser combinator es una higher-order function que toma uno o más parsers y devuelve un nuevo parser. Te permiten construir parsers complejos componiendo parsers simples, usando código ordinario en lugar of a separate grammar language.

¿Cuándo debería seguir usando un parser generator?

Recurre a un generator cuando estés parseando un lenguaje de programación completo, cuando necesites máximo throughput de parsing, o cuando tu equipo ya tenga expertise profunda en un ecosistema de generator específico. Para DSLs pequeños, usualmente no vale el overhead.

¿Son lentos los parser combinators?

Son más lentos que los parsers LR escritos a mano o generados, pero la brecha es irrelevante para la mayoría de los casos de uso de DSLs. Un parser combinator típico maneja miles de tokens por milisegundo. Eso es suficientemente rápido para archivos de config, query strings y rule engines.

¿Puedo usar parser combinators en lenguajes distintos de TypeScript?

Absolutamente. El patrón es independiente del lenguaje. nom en Rust, Parsec en Haskell, parsy en Python y attoparsec en Haskell son todas libraries maduras y ampliamente usadas.

La próxima vez que necesites un lenguaje diminuto dentro de un bounded context, pregúntate si realmente necesitas un archivo de gramática y un paso de code generation. Para la mayoría de los DSLs, la respuesta es no. Cien líneas de código combinator te darán un parser en el que puedes hacer step through con el debugger, extender sin regenerar nada y realmente entender seis meses después.