espeto
v0.3.0
Published
Pipe-centric DSL for building CLIs.
Readme
Espeto
Lenguaje funcional pequeño para construir CLIs. Pipe-céntrico, Elixir-flavored, optimizado para que los LLMs lo escriban perfecto.
Estado actual: v0.2.1 en npm + Homebrew tap (mayo 2026). Instalable con npm i -g espeto o brew tap FrancisVega/espeto && brew install espeto. Build, watch, REPL, LSP + extensión VS Code, multi-subcomando, identificadores mágicos __file__/__dir__. Package manager (moraga): 8 comandos (install/add/remove/update/outdated/link/unlink/publish), git-based descentralizado, manifest moraga.esp, lock moraga.lock con checksums TOFU, deps de GitHub.
La idea
"Espeto" en el sur de España es la brocheta donde se ensartan sardinas para asarlas a la brasa. La metáfora del lenguaje es exactamente esa:
- Las sardinas son los datos.
- El espeto es el operador pipe
|>. - Un programa idiomático se lee como una brocheta: el dato entra por un lado, sale transformado por el otro.
"sardinas" |> upcase |> printEso ya es un programa válido en Espeto.
Echa un vistazo
Hola pipe
"hola" |> printEncadena transformaciones
" sardinas frescas " |> trim |> upcase |> print
# SARDINAS FRESCASDefine funciones
def saludar(name) = "Hola, #{name}!"
"mundo" |> saludar |> print
# Hola, mundo!Un CLI completo en 6 líneas
hola.esp:
cmd hola do
arg name: str
flag loud: bool = false
greeting = "Hola, #{name}!"
greeting |> when(loud, upcase) |> print
endY se ejecuta:
espeto run hola.esp -- --name Mundo --loud
# HOLA, MUNDO!
espeto run hola.esp -- --help
# (auto-generado a partir de las declaraciones)CLI multi-subcomando con program
Un solo fichero puede agrupar varios subcomandos con flags compartidas:
program todo do
desc "todo manager"
version "0.1.0"
flag loud: bool = false
cmd add do
arg item: str
"added: #{item}" |> when(loud, upcase) |> print
end
cmd remove do
arg id: int
"removed: #{id}" |> print
end
endespeto run todo.esp -- add milk
# added: milk
espeto run todo.esp -- --loud add milk
# ADDED: MILK
espeto run todo.esp -- --help
# Usage: todo <command> [options]
# Commands: add, remove
# ...--help y --version salen gratis. Cada subcomando hereda las flags del program.
Modo watch
Re-ejecuta el programa cada vez que cambia el fichero o cualquiera de sus imports relativos:
espeto run --watch hola.esp -- --name Mundo
# ▸ ran in 2ms — watching 1 file
# (editas hola.esp y guarda)
# ▸ ran in 1ms — watching 1 fileTambién funciona como -w. Sigue corriendo hasta Ctrl-C. Los errores de parseo o runtime no matan el watcher; el siguiente cambio relanza.
CLI realista con JSON, filtros y manejo de errores
users.esp:
import "./format" only [bullet]
cmd users do
desc "Lista usuarios activos desde un fichero JSON. Filtra por edad mínima."
version "0.1.0"
arg file: str, desc: "ruta al fichero JSON"
flag min_age: int = 0, short: "a", desc: "edad mínima"
flag loud: bool = false, short: "l", desc: "saluda gritando"
data = try do
file |> read |> parse_json
rescue err =>
raise("No pude leer #{file}: #{err}")
end
data
|> filter(fn u => u.active and u.age >= min_age)
|> sort_by(.age)
|> map(.name)
|> map(fn n => saludar(n, loud))
|> each(fn s => bullet(s) |> print)
end
def saludar(name, loud) do
base = "Hola, #{name}!"
if loud do upcase(base) else base end
endformat.esp:
def bullet(s) = "• #{s}"Filosofía de diseño
Espeto se construye sobre tres ideas que se sostienen unas a otras:
1. Pipe-céntrico
El operador |> no es uno más: es la columna vertebral. Todo programa idiomático fluye datos de izquierda a derecha. La sintaxis está optimizada para que el pipe sea siempre la opción más cómoda.
A la derecha del |> puedes poner:
- una llamada con argumentos:
x |> f(y)≡f(x, y) - un nombre pelado (función arity-1):
x |> upcase≡upcase(x) - una lambda inline:
x |> (fn n => n * 2) - un acceso a campo:
user |> .name≡user.name - una llamada con placeholder
_para colocar el LHS en otra posición:6 |> div(30, _)≡div(30, 6)
2. LLM-friendly y token-económico
El lenguaje está diseñado para que un LLM lo escriba bien sin equivocarse. Eso significa:
- Una sola forma canónica de hacer cada cosa. Cero "hay 5 maneras de escribir lo mismo".
- Sin coerciones implícitas:
1 + 1.0es error.1 == 1.0esfalse. Cero magia. - Sintaxis predecible: siempre
def/defp, siempredo/end, siemprefn x => expr. - Errores con posición: cada error trae fichero, línea, columna y un caret apuntando.
Spec en un comando: espeto docs
espeto docs imprime la referencia completa del lenguaje en markdown a stdout (sintaxis, operadores, control flow, todos los builtins con signature + ejemplo). Pensado para que un LLM aprenda Espeto sin leer el repo.
# Pegarlo a un chat
espeto docs | pbcopy
# Snapshot versionado para tu proyecto
espeto docs > spec.mdPatrón en tu proyecto Espeto: añade un CLAUDE.md o AGENTS.md que apunte a spec.md, y refrescalo con espeto docs > spec.md cuando subas de versión. Así tu LLM tiene la referencia del lenguaje siempre a mano y no improvisa sintaxis.
3. CLIs como ciudadanos de primera
cmd es un keyword nativo, no una librería. Un fichero .esp con un cmd es un CLI ejecutable, con --help auto-generado. Sin argparse, sin boilerplate, sin import de librerías de terceros para algo que es el caso de uso #1 del lenguaje.
Spec en una página
| Área | Resumen |
|---|---|
| Top-level | import, def/defp, cmd, program. Un cmd o program por fichero. |
| cmd block | do/end. Meta (desc/version) → declaraciones (arg/flag) → body. |
| program block | Agrupa varios cmd como subcomandos con flags compartidas. --help/--version auto. |
| Bindings | x = expr (sin let). Rebinding sí. Valores inmutables. |
| Pipe \|> | First-arg piping. RHS: llamada / nombre pelado / lambda / .field. Placeholder _ para reposicionar LHS. |
| Funciones | def f(x) = expr (one-liner) o def f(x) do ... end (bloque). defp para privadas. |
| Lambdas | fn x => expr, fn(x,y) => expr, fn() => expr. Solo expresión. |
| Errores | Excepciones con raise. Auto-rescue en cmd. try do ... rescue err => ... end para local. Variantes try_* para Result-style. |
| Módulos | Fichero = módulo. Imports relativos ("./x") o por nombre ("ansi" resuelve buscando .espetos/<name>/ y luego packages/<name>/ ascendiendo). only [a, b as c] opcional. |
| Source bindings | __file__ / __dir__ auto-inyectados por módulo (definition-site, closure-captured). |
| Tipos | int, float, str, bool, nil, list, map, fn. Sin coerción. |
| Igualdad | == único, estructural. 1 == 1.0 es false. |
| Truthiness | Estricto: solo bool en if. and/or/not requieren bool. |
| Control | Solo if/else if/else do…end, expresión-valuada. Sin ternario, sin case en v0. |
| Strings | "..." con #{x}. Comentarios #. (Multilínea """...""" planeado para v1.) |
| Concat | Solo funciones (concat, join). Sin operadores <>/++. |
| Stdlib | ~50-60 funcs auto-cargadas. snake_case. ? en predicados. Sin loops. Sync. |
Stdlib v0 (auto-cargada)
| Categoría | Funciones |
|---|---|
| I/O | print, read, try_read, write, try_write, exists?, env, env_or |
| Strings | upcase, downcase, trim, split, join, replace, length, starts_with?, ends_with?, contains?, slice, chars |
| Números | to_int, to_float, to_str, abs, round, floor, ceil, min, max, div, mod |
| Listas | length, head, tail, concat, map, filter, reduce, each, find, sort, sort_by, reverse, take, drop, unique, range, zip |
| Maps | keys, values, get, get_or, put, delete, has_key?, merge |
| JSON | parse_json, to_json |
| Pipe helpers | when, unless, id |
| Predicados de tipo | is_int?, is_float?, is_str?, is_bool?, is_nil?, is_list?, is_map?, is_fn? |
| Errores | raise, variantes try_* para Result-style |
Lo que NO hay en v0 (pero está planteado para v1+): pattern matching, regex, HTTP, fechas, async, tuples, atoms, TCO garantizado.
Identificadores mágicos: __file__ / __dir__
Cada módulo .esp tiene dos bindings auto-inyectados con el path absoluto del fichero fuente:
cmd active_users do
users = parse_json(read("#{__dir__}/users.json"))
users |> filter(.active) |> map(.name) |> each(print)
end- Definition-site / closure: si
lib.espdefinedef data_path() = "#{__dir__}/data", al importar y llamar desde otro módulo,__dir__resuelve al dir delib.esp(donde vive el texto), no al del importador. - REPL: no están bindeados (no hay archivo asociado). Acceso →
undefined: __file__. - Built binaries (
espeto build): los paths preservan los valores de build-time. Para shippear data junto al binario, compón en runtime conprocess.cwd()-relative o variables de entorno.
Roadmap
Hitos completados (v0.1.0)
| Hito | Deliverable |
|---|---|
| 0 | Setup proyecto: pnpm + tsx + vitest, layout, errors con source spans, bin |
| 1 | "hola" \|> print corre — lexer, parser, evaluator base, primer builtin |
| 2 | "x" \|> upcase \|> print — más builtins, chains de pipes |
| 3 | def f(x) = ... + uso — funciones de usuario, scope, llamadas |
| 4 | hola.esp completo — cmd, arg, flag, interpolación, when |
| 5 | espeto repl — REPL con env persistente |
| 6 | Imports + módulo separado — import "./x" only [..], resolución de paths |
| 7 | Control flow + listas + maps + lambdas — if/else, literales, acceso a campos |
| 8 | users.esp completo — JSON, sort_by, .field, try/rescue, stdlib amplia |
| 9 | Errores formateados pretty — source spans con snippet + caret |
v0.2.0 (en npm)
espeto build— empaquetado standalone vía Bun--compileespeto run --watch— re-ejecución on-changeespeto lsp— servidor LSP por stdio + extensión VS Code (editors/vscode/)program <name>— multi-subcomando con flags compartidas- Pipe placeholder
_— reposicionar LHS en cualquier argumento __file__/__dir__— paths source-relative por módulo- Stdlib JSDoc → manifest → hover docs en LSP
- Package manager
moraga— manifest, lock, install, add/remove/update/outdated, link/unlink, publish
Próximo
- v1: transpiler
.esp → .js(AOT real, sin runtime embebido) - v1: regex, HTTP, fechas, pattern matching, async
Stack técnico
- TypeScript / Node 20+
- Tree-walking interpreter (sin compile step a JS, sin VM intermedia)
- Parser: recursive descent escrito a mano
- AST: discriminated unions tipadas con campo
kind - Source spans desde día 1: cada token y cada nodo conocen su
{ file, line, col, length } - Tooling:
pnpm,tsx(sin build step en dev),vitest(~1340 tests) - Build de la CLI:
esbuildbundle →dist/cli.js,dist/runtime.js,dist/lsp.js - Build de programas
.esp:espeto build→ Bun--compile(binario autocontenido)
No hay async, ni FFI a JS, ni regex/HTTP/dates en v0 — Espeto es síncrono y autocontenido. v1 traerá AOT real (transpiler .esp → .js) y los features pendientes.
Estructura del proyecto
espeto-language/
├── src/
│ ├── lexer.ts # tokenizer + posiciones
│ ├── parser.ts # recursive descent → AST
│ ├── ast.ts # tipos discriminated unions
│ ├── evaluator.ts # tree-walking interpreter
│ ├── env.ts # entornos / scoping
│ ├── errors.ts # EspetoError con source span
│ ├── values.ts # representación runtime de valores
│ ├── cmd.ts # parseo argv → args/flags + help auto
│ ├── imports.ts # ModuleLoader, resolver, source bindings
│ ├── run.ts # entry point del runtime
│ ├── watch.ts # `espeto run --watch`
│ ├── repl.ts # REPL basado en readline
│ ├── cli.ts # bin: `espeto run|build|repl|lsp|...`
│ ├── build.ts # `espeto build`: empaqueta .esp en binario via Bun
│ ├── lsp/ # servidor LSP (stdio) + análisis para hover/go-to-def
│ │ └── server.ts, analyze.ts, generated.ts (manifest auto)
│ └── stdlib/ # prelude auto-cargado, JSDoc → hover docs
│ └── index.ts, io.ts, strings.ts, lists.ts, maps.ts, numbers.ts, json.ts, pipe.ts, ...
├── editors/vscode/ # extensión VS Code (LSP client + grammar)
├── scripts/build-manifest.ts # extrae JSDoc de stdlib → MANIFEST para LSP
├── examples/ # cada carpeta = test integración (01-hello/ ... 14-file/)
├── tests/ # ~770 tests vitest
├── bin/espeto # shim que lanza tsx src/cli.ts
└── package.json, tsconfig.json¿Por qué "Espeto"?
Por el espeto de sardinas malagueño. La brocheta donde se asan a la brasa. La metáfora del pipe |> y los datos ensartados como sardinas se sostiene sola — y un nombre con sabor a Mediterráneo le sienta bien a un lenguaje pensado con cariño.
Cómo arrancar
Con Homebrew (macOS y Linuxbrew):
brew tap FrancisVega/espeto
brew install espeto
espeto run hola.esp
espeto replDesde npm:
npm install -g espeto
# o: pnpm add -g espeto
espeto run hola.esp
espeto replDesde fuente:
git clone https://github.com/<...>/espeto-language.git
cd espeto-language
pnpm install
pnpm build
./bin/espeto run examples/01-hello/cmd.esp
./bin/espeto replSubcomandos:
espeto run [-w|--watch] <file.esp> [-- cmd-args...] ejecuta un .esp
espeto build <file.esp> -o <out> [--target T] empaqueta en binario standalone
espeto test [-w|--watch] [path] corre *_test.esp bajo path
espeto docs imprime referencia del lenguaje (markdown)
espeto repl REPL interactivo
espeto lsp servidor LSP (stdio)
espeto install instala deps de moraga.esp en .espetos/
espeto add <url>@<ver> [...] añade deps + install (--dev / --as <name>)
espeto remove <url> [...] quita deps + install
espeto update [<url>...] actualiza deps a latest (--pre incluye pre-releases)
espeto outdated [--pre] [--json] [--exit-code] lista deps con versiones nuevas
espeto link <url> <path> linkea dep a path local (moraga.local.esp)
espeto unlink <url> [...] quita link
espeto publish [--dry-run] [--allow-dirty] tag + push v<version> a origin
espeto --help / --versionEditor support
LSP
espeto lsp arranca un servidor LSP por stdio. Capacidades:
- Hover: docs Markdown sobre builtins (signature + summary + ejemplos), funciones locales, args, flags, locales, params de lambda/fn,
__file__/__dir__. - Go to definition: builtins (a un stub generado), funciones locales, args, flags, locales.
- Diagnostics en vivo: errores de lex/parse publicados en cada cambio de documento.
- Completion scope-aware: keywords + builtins (con docs) + locales del cmd/fn actual (args, flags, params, lets).
- Find references y Rename simbólico: lets, fns top-level, args, flags, params de fn/lambda y rescue err.
- Document symbols (Outline) y Folding ranges para
cmd/fn/program/test/tryy lambdas multilínea. - Signature help al teclear
(o,para builtins y fns user. - Semantic tokens (
function/parameter/variablecon modifierdefaultLibrary) para coloreado contextual.
Las docs de stdlib se extraen del JSDoc en src/stdlib/*.ts vía pnpm build:manifest y se compilan en src/lsp/generated.ts.
VS Code extension
En editors/vscode/. Arranca espeto lsp y aplica grammar de syntax highlighting:
cd editors/vscode
pnpm install
pnpm package # genera espeto-*.vsix
code --install-extension espeto-*.vsixPackage manager (moraga)
Espeto se distribuye sus paquetes con un manager git-based descentralizado, sin registry central. Identifier completo: github.com/<owner>/<repo>@<version> — copy-paste directo desde la barra del navegador. Solo github.com en v0; multi-host (gitlab/codeberg) cuando aparezca el primer caso.
Manifest moraga.esp
Map literal Espeto, JSON-subset con string keys siempre. Schema mínimo:
{
"name": "myproject",
"version": "0.1.0",
"espeto": ">= 0.1.0",
"deps": {
"github.com/foo/ansi": "1.0.0",
"github.com/bar/json": {"version": "2.1.0", "as": "bar_json"}
},
"dev_deps": {}
}- Versioning exact-only — sin
^/~/ranges. Una sola forma canónica. La excepción es el campoespeto(constraint del compilador), que admite>=y<combinables con,. - Aliases (
"as": "<name>") sólo en el manifest raíz, para resolver colisiones entre deps. - Overrides opcional al top-level para forzar una versión cuando hay conflicto transitivo.
Comandos
espeto install # popula .espetos/ desde manifest+lock
espeto add github.com/foo/[email protected] # añade dep + install
espeto add --dev github.com/foo/[email protected] # añade dev_dep
espeto add --as fancy github.com/x/[email protected] # con alias (single dep)
espeto remove github.com/foo/ansi # quita dep + install
espeto update [<url>...] # actualiza al latest tag
espeto outdated [--pre] [--json] # lista deps desactualizadas
espeto link github.com/foo/ansi ../ansi-local # apunta a path local
espeto unlink github.com/foo/ansi # deshace el link
espeto publish [--dry-run] # tag + push v<version>Filesystem
proyecto/
├── moraga.esp # commiteado, manifest principal
├── moraga.local.esp # gitignored, links locales (opcional)
├── moraga.lock # commiteado, sha-pinned + sha256 checksums
└── .espetos/ # gitignored, packages instalados (symlinks)
├── ansi/
│ └── ansi.esp
└── json/
└── json.esp
~/.espeto/cache/ # global, content-addressed por sha
└── github.com/foo/ansi/<sha>/...El resolver de imports busca primero en .espetos/<name>/<name>.esp, luego en packages/<name>/<name>.esp, ascendiendo desde el fichero importador. Subir un nivel encuentra deps del proyecto consumidor. import "ansi" funciona igual sea el package linked, instalado o in-repo.
Lock + TOFU
moraga.lock (commiteado) registra cada dep resuelta con version, sha (commit) y checksum (Merkle h1:<sha256-hex> cross-host). En install, el cache valida el checksum esperado — TOFU strict. Manifest sin cambios + lock presente → cero llamadas API (knownSha skipea resolveSha).
Linking local (moraga.local.esp)
Para desarrollar un package y un consumidor en la misma máquina sin publicar:
{
"links": {
"github.com/foo/mi-package": "../mi-package"
}
}Gitignored — es estado local, como docker-compose.override.yml. espeto link lo escribe; espeto unlink lo limpia. El package aún tiene que estar declarado en moraga.esp (el link redirige, no añade).
Publish
espeto publish (sin servidor, wrapper de git):
- Valida manifest (
name,version, entrypoint<name>.espparsea, sin links activos). - Verifica working tree limpio + branch == default-branch del remote + tag
v<version>no existe. git tag -a v<version>+git push origin refs/tags/v<version>.
--dry-run corre todas las validaciones (incluye ls-remote) sin tocar refs. --allow-dirty salta el check de working tree.
Auth
Dos pathways separados:
- API tarball / tag listing:
$GITHUB_TOKENenv var →Authorization: Bearer <tok>. Necesario para repos privados y para subir el rate-limit de 60/h a 5000/h. Sin fichero de credenciales en v0. git pushen publish: 100% delegado al git CLI (SSH key, credential helper, etc.). Sin mezcla de pathways.
Filosofía
- Cero registry central — todo vive en repos git.
- Una sola forma canónica de hacer cada cosa (LLM-friendly).
- Lock siempre commiteado, install reproducible.
- Sin
--frozenflag en v0 — siempre clean rebuild de.espetos/.
Distribuir como binario
Un programa .esp se puede empaquetar en un ejecutable standalone que no requiere Node ni espeto en la máquina destino:
espeto build hola.esp -o hola
./hola Mundo --loud
# HOLA, MUNDO!Resuelve recursivamente todos los import "./...", así que un programa multi-fichero se empaqueta entero en un solo binario.
Cross-compilar
espeto build hola.esp -o hola --target linux-arm64Targets soportados: darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64. Default: la plataforma actual.
Requisitos y caveats
- Necesita Bun instalado en la máquina donde haces el build (no en la destino).
espeto buildlo invoca por debajo. - No es un compilador AOT: el binario embebe el intérprete + tu fuente
.espy la evalúa en runtime. Funcionalmente es una distribución autocontenida, no código nativo. - Tamaño típico: ~55-90 MB por binario (Bun runtime embebido).
Licencia
MIT — ver LICENSE.
