* notes / rust
Cómo me inventé un lenguaje en Rust: JS con sintaxis más ligera
— Un experimento en construcción de lenguajes: lexer con logos, parser Pratt hecho a mano, AST tipado y un intérprete tree-walking que ejecuta una variante de JavaScript donde 'fun' sustituye a 'function'.
Hace unos meses cedí a una tentación vieja: escribir mi propio lenguaje. No uno serio (el mundo no necesita otro lenguaje serio), sino una variante ligera de JavaScript con menos boilerplate sintáctico. La palabra function me ha parecido siempre desproporcionada para lo que designa; quería poder escribir fun foo(x) { return x + 1 }. Lo bauticé internamente como funlang, lo implementé en Rust en sucesivas tardes y, aunque jamás verá la luz como producto, el proceso me enseñó más sobre semántica de JavaScript del que diez años usándolo me habían enseñado.
Contexto: por qué Rust y por qué tree-walking
Para un experimento de este calibre, tres lenguajes competían razonablemente: OCaml (el caballo histórico de los compiladores), Go (por su pragmatismo) y Rust. Opté por Rust por dos motivos concretos. Primero, la suma tipos (enums con datos) son el modelo natural para representar un AST: cada variante captura un tipo de nodo, y el compilador me obliga a manejar todas en cada match. Segundo, los crates del ecosistema (logos para lexing, chumsky o nom para parsing, ariadne para diagnósticos) cubren el 80 % del andamiaje sin sacrificar control.
En cuanto a la estrategia de ejecución, descarté la transpilación a JS por aburrida. Asimismo, descarté generar bytecode propio por excesiva para el alcance. Quedó el tree-walking interpreter: recorrer el AST recursivamente evaluando cada nodo. Es la opción más lenta, sí, pero también la más didáctica: cada decisión semántica (hoisting, closures, this) se materializa como un puñado de líneas legibles.
El pipeline en un vistazo
El lexer con logos
Empecé por el lexer porque es la pieza más mecánica y la que más rinde por línea escrita. El crate logos genera, mediante derive macro, una máquina de estados optimizada a partir de una declaración tipo enum. Hereda gran parte de la velocidad de re2c sin sacrificar legibilidad.
use logos::Logos;
#[derive(Logos, Debug, PartialEq, Clone)]
#[logos(skip r"[ \t\n\f]+")]
#[logos(skip r"//[^\n]*")]
pub enum Token<'src> {
#[token("fun")]
Fun,
#[token("let")]
Let,
#[token("return")]
Return,
#[token("if")]
If,
#[token("else")]
Else,
#[token("true")]
True,
#[token("false")]
False,
#[regex(r"[a-zA-Z_][a-zA-Z0-9_]*")]
Ident(&'src str),
#[regex(r"-?[0-9]+(\.[0-9]+)?", |lex| lex.slice().parse::<f64>().ok())]
Number(f64),
#[regex(r#""([^"\\]|\\.)*""#, |lex| {
let s = lex.slice();
Some(s[1..s.len()-1].to_string())
})]
Str(String),
#[token("(")] LParen,
#[token(")")] RParen,
#[token("{")] LBrace,
#[token("}")] RBrace,
#[token(",")] Comma,
#[token(";")] Semicolon,
#[token("=")] Eq,
#[token("+")] Plus,
#[token("-")] Minus,
#[token("*")] Star,
#[token("/")] Slash,
#[token("<")] Lt,
#[token(">")] Gt,
}Cabe destacar que el lexer no toma ownership de la entrada; los identificadores se devuelven como &'src str, evitando alocaciones innecesarias en el caso más frecuente. Las cadenas literales, en cambio, se materializan como String porque pueden contener escapes que requieren transformación.
El parser: Pratt a mano
Para el parser deliberé entre chumsky (elegantísimo pero con curva de aprendizaje) y nom (combinatorial pero menos cómodo para precedencias). Acabé escribiendo un Pratt parser a mano. La razón es estética: un Pratt parser tiene cuarenta líneas, deja la precedencia explícita en una tabla y funciona impecablemente para lenguajes tipo C.
Antes del parser, las definiciones del AST:
#[derive(Debug, Clone)]
pub enum Stmt {
Let(String, Expr),
Expr(Expr),
Return(Option<Expr>),
Fun {
name: String,
params: Vec<String>,
body: Vec<Stmt>,
},
If {
cond: Expr,
then_branch: Vec<Stmt>,
else_branch: Option<Vec<Stmt>>,
},
}
#[derive(Debug, Clone)]
pub enum Expr {
Number(f64),
Str(String),
Bool(bool),
Ident(String),
Binary {
op: BinOp,
left: Box<Expr>,
right: Box<Expr>,
},
Call {
callee: Box<Expr>,
args: Vec<Expr>,
},
Assign(String, Box<Expr>),
}
#[derive(Debug, Clone, Copy)]
pub enum BinOp { Add, Sub, Mul, Div, Lt, Gt }Y el núcleo del parser:
pub struct Parser<'src> {
tokens: Vec<Token<'src>>,
pos: usize,
}
impl<'src> Parser<'src> {
fn peek(&self) -> Option<&Token<'src>> { self.tokens.get(self.pos) }
fn bump(&mut self) -> Option<Token<'src>> {
let t = self.tokens.get(self.pos).cloned();
self.pos += 1;
t
}
pub fn parse_stmt(&mut self) -> Stmt {
match self.peek() {
Some(Token::Fun) => self.parse_fun(),
Some(Token::Let) => self.parse_let(),
Some(Token::Return) => self.parse_return(),
Some(Token::If) => self.parse_if(),
_ => {
let e = self.parse_expr(0);
self.expect(Token::Semicolon);
Stmt::Expr(e)
}
}
}
fn parse_fun(&mut self) -> Stmt {
self.bump(); // fun
let name = if let Some(Token::Ident(n)) = self.bump() {
n.to_string()
} else { panic!("expected name after fun") };
self.expect(Token::LParen);
let mut params = Vec::new();
while let Some(Token::Ident(p)) = self.peek().cloned() {
params.push(p.to_string());
self.bump();
if matches!(self.peek(), Some(Token::Comma)) { self.bump(); }
}
self.expect(Token::RParen);
self.expect(Token::LBrace);
let mut body = Vec::new();
while !matches!(self.peek(), Some(Token::RBrace)) {
body.push(self.parse_stmt());
}
self.expect(Token::RBrace);
Stmt::Fun { name, params, body }
}
/// Pratt parsing con binding power
fn parse_expr(&mut self, min_bp: u8) -> Expr {
let mut lhs = match self.bump().unwrap() {
Token::Number(n) => Expr::Number(n),
Token::Str(s) => Expr::Str(s),
Token::True => Expr::Bool(true),
Token::False => Expr::Bool(false),
Token::Ident(name) => Expr::Ident(name.to_string()),
Token::LParen => {
let e = self.parse_expr(0);
self.expect(Token::RParen);
e
}
t => panic!("unexpected token: {:?}", t),
};
loop {
let (op, l_bp, r_bp) = match self.peek() {
Some(Token::Plus) => (BinOp::Add, 1, 2),
Some(Token::Minus) => (BinOp::Sub, 1, 2),
Some(Token::Star) => (BinOp::Mul, 3, 4),
Some(Token::Slash) => (BinOp::Div, 3, 4),
Some(Token::Lt) => (BinOp::Lt, 0, 1),
Some(Token::Gt) => (BinOp::Gt, 0, 1),
_ => break,
};
if l_bp < min_bp { break; }
self.bump();
let rhs = self.parse_expr(r_bp);
lhs = Expr::Binary { op, left: Box::new(lhs), right: Box::new(rhs) };
}
lhs
}
fn expect(&mut self, expected: Token<'src>) {
let got = self.bump();
assert!(matches!(got, Some(ref t) if std::mem::discriminant(t) == std::mem::discriminant(&expected)),
"expected {:?}, got {:?}", expected, got);
}
}Para fun foo(x) { return x + 1 }, el AST resultante es transparente: un Stmt::Fun con un solo elemento en body, a su vez un Stmt::Return envolviendo una Expr::Binary con operador Add.
El intérprete: tree-walking con scopes encadenados
El núcleo semántico vive en eval. La parte sutil son los scopes: cada función crea un entorno que apunta a su entorno léxico de origen, lo cual reproduce el comportamiento de cierre de JavaScript.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
type EnvRef = Rc<RefCell<Env>>;
pub struct Env {
vars: HashMap<String, Value>,
parent: Option<EnvRef>,
}
impl Env {
pub fn new(parent: Option<EnvRef>) -> EnvRef {
Rc::new(RefCell::new(Self { vars: HashMap::new(), parent }))
}
pub fn get(&self, name: &str) -> Option<Value> {
self.vars.get(name).cloned()
.or_else(|| self.parent.as_ref().and_then(|p| p.borrow().get(name)))
}
pub fn set(&mut self, name: String, value: Value) {
self.vars.insert(name, value);
}
}
#[derive(Clone)]
pub enum Value {
Number(f64),
Str(String),
Bool(bool),
Nil,
Function {
params: Vec<String>,
body: Vec<Stmt>,
closure: EnvRef,
},
}
pub fn eval_stmt(stmt: &Stmt, env: &EnvRef) -> Option<Value> {
match stmt {
Stmt::Let(name, e) => {
let v = eval_expr(e, env);
env.borrow_mut().set(name.clone(), v);
None
}
Stmt::Fun { name, params, body } => {
let f = Value::Function {
params: params.clone(),
body: body.clone(),
closure: Rc::clone(env),
};
env.borrow_mut().set(name.clone(), f);
None
}
Stmt::Return(opt) => Some(opt.as_ref().map(|e| eval_expr(e, env)).unwrap_or(Value::Nil)),
Stmt::Expr(e) => { eval_expr(e, env); None }
Stmt::If { cond, then_branch, else_branch } => {
if truthy(&eval_expr(cond, env)) {
eval_block(then_branch, env)
} else if let Some(else_b) = else_branch {
eval_block(else_b, env)
} else { None }
}
}
}
fn eval_block(stmts: &[Stmt], env: &EnvRef) -> Option<Value> {
for s in stmts {
if let Some(v) = eval_stmt(s, env) { return Some(v); }
}
None
}
fn eval_expr(expr: &Expr, env: &EnvRef) -> Value {
match expr {
Expr::Number(n) => Value::Number(*n),
Expr::Str(s) => Value::Str(s.clone()),
Expr::Bool(b) => Value::Bool(*b),
Expr::Ident(name) => env.borrow().get(name).unwrap_or(Value::Nil),
Expr::Binary { op, left, right } => {
let l = eval_expr(left, env);
let r = eval_expr(right, env);
apply_binop(*op, l, r)
}
Expr::Call { callee, args } => {
let func = eval_expr(callee, env);
let evaluated: Vec<_> = args.iter().map(|a| eval_expr(a, env)).collect();
match func {
Value::Function { params, body, closure } => {
let call_env = Env::new(Some(closure));
for (p, v) in params.iter().zip(evaluated) {
call_env.borrow_mut().set(p.clone(), v);
}
eval_block(&body, &call_env).unwrap_or(Value::Nil)
}
_ => panic!("not callable"),
}
}
Expr::Assign(name, e) => {
let v = eval_expr(e, env);
env.borrow_mut().set(name.clone(), v.clone());
v
}
}
}
fn truthy(v: &Value) -> bool {
match v {
Value::Bool(b) => *b,
Value::Nil => false,
Value::Number(n) => *n != 0.0,
Value::Str(s) => !s.is_empty(),
Value::Function { .. } => true,
}
}
fn apply_binop(op: BinOp, l: Value, r: Value) -> Value {
match (op, l, r) {
(BinOp::Add, Value::Number(a), Value::Number(b)) => Value::Number(a + b),
(BinOp::Sub, Value::Number(a), Value::Number(b)) => Value::Number(a - b),
(BinOp::Mul, Value::Number(a), Value::Number(b)) => Value::Number(a * b),
(BinOp::Div, Value::Number(a), Value::Number(b)) => Value::Number(a / b),
(BinOp::Lt, Value::Number(a), Value::Number(b)) => Value::Bool(a < b),
(BinOp::Gt, Value::Number(a), Value::Number(b)) => Value::Bool(a > b),
(BinOp::Add, Value::Str(a), Value::Str(b)) => Value::Str(a + &b),
_ => Value::Nil,
}
}Las trampas que JavaScript me obligó a respetar
Implementar incluso este subconjunto reducido me forzó a tomar decisiones que JavaScript resuelve de forma idiosincrásica:
- Hoisting: en JS, las declaraciones
functionson visibles antes de su línea de definición. Para emular ese comportamiento añadí una pasada previa por cada bloque que registra todas lasStmt::Funantes de iterar la evaluación. - Closures: el campo
closure: EnvRefenValue::Functiones lo que permite que una función capture variables del entorno léxico donde se definió. La elección deRc<RefCell<_>>se debe a que múltiples closures pueden compartir y mutar el mismo entorno. this: lo dejé deliberadamente fuera. Implementarlo correctamente requiere distinguir entre invocación como método (obj.f()) y como función libre (f()), y propagarthispor el call stack. Es la zona del lenguaje donde JavaScript ha tomado más decisiones contraintuitivas.- Coerciones: limité
+aNumber+NumberyStr+Str. La coerción implícita de JavaScript ("3" + 2 === "32") es famosa por producir bugs y nada me obligaba a replicarla en funlang.
Aprendizajes
| Área | Lo que descubrí |
|---|---|
| AST como enum | Rust convierte la exhaustividad en garantía del compilador |
| Pratt parsing | 40 líneas para precedencias claras y extensibles |
Closures con Rc<RefCell> | Sencillo conceptualmente, exige cuidado con ciclos |
| Hoisting | Más fácil de implementar que de entender |
this | El precio de no implementarlo es ridículamente bajo |
Closure
Construir incluso un lenguaje ínfimo me cambió la forma de leer JavaScript. Cada peculiaridad de la especificación (let vs var, Function.prototype.bind, el orden de evaluación de operadores ternarios) deja de ser arbitraria y se revela como un compromiso que alguien tomó frente a un AST muy parecido al que escribí. Si Rust hizo placentero el ejercicio fue, en buena medida, porque sus enums y su pattern matching transforman la teoría de los lenguajes formales en código que parece, sencillamente, evidente. Recomiendo el experimento a cualquiera que quiera entender por qué su intérprete favorito decide lo que decide: pasar quince noches escribiendo el propio enseña más que diez libros.