Guía de Fitz¶
Estado: viva — cubre lo que el intérprete ejecuta hoy y lo que el compilador (
fitz build) produce como binario nativo.
Esta guía es para developers que vienen de Python, TypeScript, Vue o similares y quieren aprender Fitz escribiendo programas reales. Está pensada para leerse de arriba a abajo: cada capítulo asume lo del anterior.
Lo que ves acá funciona hoy contra el binario del repo. Si un ejemplo no corre, es un bug de la guía, del intérprete o del compilador — abrí un issue.
¿Es tu primera vez con Fitz y querés que te lleven paso a paso desde la instalación? El curso
Fitz de 0 a expertoes la puerta de entrada pedagógica: un proyecto que crece capítulo a capítulo desde "hola mundo" hasta una app real. Esta guía sigue siendo el lugar para referencia feature por feature.
Índice¶
Parte 1 — Empezando 1. Bienvenida 2. Tu primer programa
Parte 2 — Datos y expresiones 3. Variables y tipos primitivos 4. Operadores 5. Strings
Parte 3 — Control de flujo y colecciones 6. Booleanos y lógica 7. if / else 8. Loops 9. Listas, mapas y rangos 10. Match
Parte 4 — Abstracción
11. Funciones
12. Tipos con type
13. Métodos y mutación
Parte 5 — Errores 14. Result y manejo de errores 15. Errores y mensajes
Parte 6 — Organización 16. Módulos 16b. Package manager
Parte 7 — HTTP nativo y concurrencia 17. HTTP nativo 18. Docs automáticas 19. Async y concurrencia
Parte 8 — Compilar
20. fitz build — compilar a binario nativo
Parte 9 — Interop 21. Interop Python
Parte 10 — Tooling
22. Soporte para editores
23. fitz fmt — formateador automático
24. fitz test — testing built-in
25. fitz dev — hot reload
26. fitz repl — REPL interactivo
27. fitz lint — linter de patrones
Parte 11 — Web first-class 28. Auth nativa 29. WebSockets tipados 30. Jobs sin Celery 31. Postgres + ORM nativo
Parte 12 — Operacional 32. Variables de entorno 33. Observability — logs, spans, métricas, OTel
Parte 13 — CLI y cerrando
34. CLI builder nativo (@command)
35. Deployment ciudadano primera clase
36. Plantillas y boilerplates
37. Qué sigue
1. Bienvenida¶
Fitz es un lenguaje nuevo, pensado para gente que ama la ergonomía de Python y TypeScript pero se cansó de la lentitud del primero y del bagaje histórico del segundo. Algunas ideas centrales:
- Sintaxis liviana, inspirada en Python y TypeScript. Punto y coma opcional, llaves para los bloques, indentación libre.
- Tipado gradual con chequeo estático: las anotaciones de tipo
son opcionales, pero cuando están,
fitz checkyfitz runlas validan en compile time (Fase 5a, cerrada). Sin anotación, el tipo se infiere o se trata comoAny(escape gradual). - HTTP como ciudadano de primera clase —
@get,@post, etc. son parte del lenguaje, no de una librería. El servidor arranca solo si tu programa registra rutas. Lo vas a ver en el capítulo 17. - Sin excepciones: los errores se manejan con
Resultymatch, estilo Rust. - Multiplataforma desde el día uno — cada release publica binarios
- extensión VSCode + imagen Docker (
ghcr.io/thegreekman76/fitz:latest) para 4 plataformas: Windows x64, Linux x64, Linux ARM64 y macOS Apple Silicon. El mismo programa Fitz corre en cualquiera; cross-compile gratis vía rustc targets desde cualquier plataforma a las otras. - Objetivo final: binario nativo. Hoy Fitz es un intérprete escrito en Rust, y eso es lo que cubre esta guía.
El nombre es por el cerro Fitz Roy, en El Chaltén, Patagonia.
Qué cubre esta guía¶
Solo lo que el intérprete ejecuta hoy:
- Variables y tipos primitivos:
Int,Float,Str,Bool,Null. - Aritmética con promoción
Int↔Float, comparaciones, igualdad. - Strings con concatenación e interpolación (
"Hola, {name}"). - Booleanos con
and/ory short-circuit. if/else/else if, tanto sentencia como expresión.while,loop,break,continue.- Listas (
[1, 2, 3]), mapas ({"k": v}), rangos (0..10). - Indexing con
xs[i]/m["k"]. for x in xsyfor i in 0..n.matchcon patrones literales, binding por identificador,_y rangos0..10.- Funciones (
fnen bloque y=>en flecha), funciones anónimas (fn(x) => x*2), closures, recursión. - Declaración de tipos con
type, instanciación (User { id: 1, name: "x" }), acceso a campos (user.name), mutación de campos (user.name = "Otro"), defaults y nullables. - Method calls sobre listas, mapas, strings e instancias:
xs.map(fn(n) => n*2),xs.push(v),m.get("k"),s.upper(). - Manejo de errores con
Result,Ok(x),Err(e),matchsobre las variantes y operador?para propagar. - Organización en archivos con
import fooyfrom foo import bar. - HTTP nativo: decoradores
@get,@post,@put,@deletepara registrar rutas, path params tipados, body deserializado contra untype, serialización JSON automática,@server(port, host)para configurar. - Builtins globales:
print,len,sleep,cors.
Qué todavía no anda¶
A esta altura, casi todo lo que aparece en la especificación de
sintaxis ya está implementado. Algunas piezas
grandes pendientes: streaming HTTP, traits / interfaces, herencia
entre types, operator overloading. Cada vez que una pieza se
cierra, esta guía suma el capítulo correspondiente.
Cómo está organizada¶
La guía está dividida en partes que se leen en orden:
- Empezando — qué es Fitz y cómo correr tu primer programa.
- Datos y expresiones — los tipos básicos y cómo se combinan.
- Control de flujo y colecciones — decidir, repetir, agrupar datos.
- Abstracción — funciones, tipos custom, métodos, mutación.
- Errores —
Resulty mensajes del intérprete. - Organización — partir el código en módulos + package
manager (
fitz.toml, deps, lockfile). - HTTP nativo y concurrencia — el diferencial de Fitz: decoradores y server automático, docs autogeneradas, async.
- Compilar —
fitz builda binario nativo standalone. - Interop —
from python import ...para reusar el ecosistema Python. - Tooling — LSP + extensión VSCode, formateador
fitz fmt, test runnerfitz test, hot reloadfitz dev, REPLfitz repl, linterfitz lint. - Cerrando — el mapa de lo que viene.
Cómo usar los ejemplos¶
Cada ejemplo de la guía vive como archivo en examples/guide/ y se
ejecuta así:
Si copiás y pegás a un archivo propio, también funciona — los ejemplos son completos, no fragmentos sueltos.
Convenciones¶
- Los nombres en código están en inglés (
name,count,greet), los comentarios y la prosa en español. - Cuando una feature tiene un hueco conocido lo digo explícito, no lo escondo. Mejor saber lo que falta que tropezarse después.
- Los snippets cortos van inline. Los programas completos viven en
examples/guide/.
Listo, ya sabés contra qué te estás peleando. En el próximo capítulo escribimos y corremos el primer programa.
2. Tu primer programa¶
Antes de escribir código, asegurate de poder ejecutarlo.
Instalar Fitz¶
Tenés dos caminos. Elegí el que te quede más cómodo.
Opción A — Binario pre-compilado (recomendado). Cada release publica binarios listos para Linux x64/ARM, macOS ARM y Windows x64 en la página de releases del repo:
Bajá el .tar.gz (o .zip para Windows) de tu plataforma, descomprimí,
y dejá el ejecutable fitz (o fitz.exe) en cualquier carpeta de tu
PATH. A partir de ahí, fitz está disponible desde cualquier
directorio del sistema.
Opción B — Compilar desde fuente. Si querés la última versión
de main o tu plataforma no tiene binario pre-compilado, necesitás
el toolchain de Rust (rustup si todavía no lo
tenés). El repo pinea la versión exacta en rust-toolchain.toml, así
que rustup la baja sola la primera vez:
El binario queda en target/release/fitz (o fitz.exe en Windows).
Copialo a algún directorio del PATH o usá la ruta completa.
Verificar la instalación¶
Si fitz --help lista los subcomandos (run, build, check,
new, test, fmt, dev, repl, lint, openapi, add,
remove, update), estás listo.
Correr un programa¶
Fitz tiene un intérprete (fitz run) que ejecuta tu archivo
directamente, y un compilador (fitz build) que produce un binario
nativo standalone. Empezamos con el intérprete:
(Si compilaste desde fuente, las rutas relativas son al directorio
desde donde corrés fitz. Si bajaste el binario pre-compilado y
querés correr los ejemplos del repo, podés clonarlo igual: git clone
https://github.com/Thegreekman76/fitz.git.)
La salida es solo la de tu programa, sin ruido:
Tu primer archivo¶
Vamos a escribir un programa propio. Creá un archivo hola.fitz en
cualquier carpeta:
// hola.fitz — El primer programa de la guía.
// Muestra: print, asignación sin tipo, interpolación de strings.
print("Hola desde Fitz 🏔️")
name = "Patagonia"
print("Hola, {name}!")
Y lo corrés:
Salida:
Este mismo ejemplo vive en examples/guide/02-hola.fitz si querés copiarlo del repo.
fitz new — arrancar un proyecto¶
Para programas más serios (con tests, deps, varios módulos), Fitz trae su propio package manager. Un proyecto se crea con:
fitz new arma la estructura mínima (fitz.toml, src/main.fitz,
.gitignore, git init automático) y fitz run sin argumentos
encuentra el [bin].main del manifest. El capítulo 16b cubre el
package manager en detalle. Para los ejemplos de esta guía, archivos
sueltos alcanzan.
Anatomía línea por línea¶
Comentarios de una línea con //. Para bloques largos también podés
usar /* ... */. El lexer los ignora, no llegan al programa.
print es un builtin: viene incluido en el intérprete, no tenés que
importarlo. Recibe uno o más argumentos, los imprime separados por
espacio y agrega un salto de línea al final (mismo comportamiento que
Python). Los strings van entre comillas dobles. Los emojis y caracteres
no-ASCII funcionan, porque Fitz trabaja en UTF-8.
Asignación. No hace falta let ni declarar el tipo: la primera vez
que aparece un identificador asignado, queda creado en el scope actual.
El tipo (Str en este caso) se infiere del valor.
Interpolación. Dentro de un string, cualquier cosa entre {...} se
evalúa y se inserta en el lugar. Acá metimos un identificador, pero
también podrías meter expresiones más complejas — vamos a verlo en el
capítulo de strings.
Errores comunes en este punto¶
Si el comando no encuentra el archivo:
Revisá la ruta. fitz run usa el directorio donde corriste el comando
como working directory, así que las rutas son relativas a ahí.
Si el archivo está pero hay un error de sintaxis, el intérprete corta con línea y columna del problema. Vamos a aprender a leer esos mensajes en el capítulo 15.
Con esto ya podés escribir, correr y ver salida. En el próximo capítulo entramos a los datos: qué tipos hay y cómo se anotan.
3. Variables y tipos primitivos¶
Una variable en Fitz es un nombre asociado a un valor. La forma más corta es:
No hace falta declarar nada antes. La primera asignación crea la variable; las siguientes la reasignan. El tipo se infiere del valor.
Los seis tipos primitivos¶
Fitz tiene seis tipos básicos. Los compuestos (listas, mapas, tipos custom instanciados) los ves en capítulos posteriores.
| Tipo | Qué es | Ejemplos |
|---|---|---|
Int |
Entero de 64 bits con signo | 42, -7, 0 |
Float |
Punto flotante de 64 bits | 3.14, -0.5, 2.0 |
Str |
String UTF-8 | "hola", "Patagonia 🏔️" |
Bool |
Booleano | true, false |
Null |
Ausencia de valor | null |
Bytes |
Secuencia de bytes binarios | b"hola", b"\x00\xff" (ver cap. 5) |
Algunas notas:
- Los
IntyFloatson tipos distintos:1y1.0no son lo mismo, aunque se mezclan en operaciones (eso lo vemos en el cap. 4). - Los strings van con comillas dobles. Las simples no se usan.
- Los emojis y caracteres no-ASCII funcionan: el lexer es UTF-8.
nulles un valor de su propio tipo (Null), no es un caso especial de otro. Imprimirnullmuestra literalmentenull.Byteses paralelo aStrpero para datos binarios (sin asumir UTF-8). Detalle completo en el cap. 5 sub-sección "Bytes".
Números legibles¶
Los literales numéricos aceptan separadores _ entre dígitos y
notación científica e/E. Ambas formas son azúcar sintáctica
del lexer — el valor numérico final es el mismo que sin la notación.
// Separadores en Int y Float — mejora legibilidad sin cambiar el valor.
let poblacion: Int = 8_000_000_000
let pi_long: Float = 3.141_592_653
// Notación científica — `e` o `E`, signo opcional.
let mil: Float = 1e3 // 1000.0
let micro: Float = 1.5e-6 // 0.0000015
let big: Float = 1.23E4 // 12300.0
// Combinados — separador adentro de mantisa y exponente.
let valor: Float = 2.997_924_58e8 // 299792458.0
Reglas:
_solo entre dígitos. Inválido:_1,1_,1__0(doble underscore).e/Econ exponente opcionalmente firmado (+/-). Exige al menos un dígito tras el signo:1e,1e+son errores del lexer.1e10produceFloat, noInt(incluso sin punto decimal). Si querés un entero grande, usá10_000_000_000(Int).
Ver examples/guide/03b-numeros-legibles.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Literales en otras bases¶
Los Int aceptan tres prefijos para expresarse en distintas bases:
| Prefijo | Base | Ejemplo | Valor |
|---|---|---|---|
0x |
Hexadecimal | 0xFF, 0xff |
255 |
0b |
Binario | 0b1010 |
10 |
0o |
Octal | 0o755 |
493 |
let max_byte: Int = 0xFF
let nibble_alto: Int = 0b1111_0000 // 240
let perms_rwxr_xr_x: Int = 0o755 // 493
let dead_beef: Int = 0xDEAD_BEEF // 3735928559
Reglas:
- Solo minúsculas en el prefijo (
0x, no0X). Los dígitos hex sí son case-insensitive (0xff==0xFF). - Separadores
_permitidos entre dígitos válidos para la base (0xDEAD_BEEF,0b1010_1010,0o7_5_5). - Overflow sobre
i64→ error claro del lexer. - Sin notación científica adentro de hex/bin/oct. La
een hex es un dígito válido (0xCAFE,0xFE), no exponente.
Combinados con format specs, podés mostrar un mismo número en distintas bases para debug:
let n: Int = 0xCAFE
print("dec: {n}, hex: {n:#x}, bin: {n:#b}, oct: {n:#o}")
// dec: 51966, hex: 0xcafe, bin: 0b1100101011111110, oct: 0o145376
Ver examples/guide/03c-bases-numericas.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Asignación¶
La forma corta es solo nombre = valor:
Pero si querés, también podés usar la keyword let:
let es opcional. Hoy las dos formas hacen exactamente lo mismo y
ambas se compilan a la misma sentencia interna. La diferencia es solo
de estilo: muchos lenguajes (Rust, JS, Swift) usan let o const para
marcar declaración nueva vs. reasignación. En Fitz esa
distinción no existe todavía. Usá la forma que te resulte más clara —
en esta guía vamos a preferir la corta para programas chicos y let
cuando hay que dejar claro que es una variable nueva en un bloque
denso.
Anotación de tipo (opcional, todavía no se valida)¶
Podés anotar el tipo con : Tipo después del nombre:
Desde la Fase 5, estas anotaciones se chequean en compile time.
Si el valor no coincide con el tipo declarado, fitz check reporta
el problema y fitz run aborta en modo strict:
El tipado gradual sigue siendo el modelo: las
anotaciones son opcionales. Sin anotación, el tipo se infiere
(let n = 42 → n es Int) o se trata como Any cuando no se
puede determinar. Si querés saltarte el chequeo en una corrida
puntual, agregale --no-typecheck al comando.
La sintaxis de anotaciones admite tipos compuestos: List<Int>,
Map<Str, User>, Result<User>, y nullable Str?.
Reasignación¶
Asignar de nuevo al mismo nombre simplemente cambia el valor:
Sin anotación, el tipo del valor también puede cambiar entre asignaciones (consecuencia del modelo gradual, no algo que recomiende):
Con anotación, asignar un valor incompatible falla en fitz check:
Ámbito (scope)¶
Una variable existe en el bloque donde se define y en los anidados,
hasta donde se cierra ese bloque. Por ahora los bloques de if,
match y while no crean su propio scope: una variable definida
adentro persiste afuera. Es un comportamiento estilo Python, no estilo
Rust. Las funciones sí crean su propio scope (cap. 11).
Esto puede sorprender — lo dejamos marcado y, si en algún momento trae problemas reales, lo reconsideramos.
Identificadores con Unicode¶
Los identificadores aceptan cualquier carácter Unicode de la categoría "Letter" (L) o "Number" (N) — letras griegas, acentos, ñ, CJK, cirílico, etc.:
let π: Float = 3.14159
let función: Str = "saludar"
let café: Int = 42
let 名前: Str = "Fitz"
let имя: Str = "Roy"
Reglas:
- El primer carácter debe ser letra Unicode o _ (no dígito).
- El resto puede ser letra, dígito o _.
- Emojis (Unicode "Symbol") quedan EXCLUIDOS — error de lex.
- Dígitos no-ASCII al inicio (٢, etc.) también rechazados.
Internamente Rust acepta Unicode identifiers desde edition 2021,
así que fitz build los pasa transparente al código generado.
Convención recomendada: ASCII para API pública (compat con tooling); Unicode OK en código interno cuando aporta claridad (constantes matemáticas, código en idioma no-inglés).
Ver examples/guide/03d-identifiers-unicode.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Ejemplo completo¶
examples/guide/03-variables.fitz:
// 02-variables.fitz — Variables y tipos primitivos.
// Sin tipo: se infiere del valor.
name = "Fitz"
count = 42
ratio = 3.14
active = true
nothing = null
print(name)
print(count)
print(ratio)
print(active)
print(nothing)
// Con anotación (se acepta y todavía no se valida).
age: Int = 30
city: Str = "El Chaltén"
print(age)
print(city)
// `let` es opcional.
let mountain = "Fitz Roy"
print(mountain)
// Reasignar es volver a usar el mismo nombre.
count = count + 1
print(count)
Salida:
En el próximo capítulo combinamos estos valores con operadores:
aritmética con promoción automática entre Int y Float,
comparaciones, igualdad y unario negativo.
4. Operadores¶
Una vez que tenés valores, queremos combinarlos. Fitz cubre los operadores que esperás de cualquier lenguaje, con algunas decisiones puntuales que vale la pena marcar.
Aritmética¶
| Operador | Significado | Ejemplo |
|---|---|---|
+ |
Suma | 2 + 3 → 5 |
- |
Resta | 10 - 4 → 6 |
* |
Multiplicación | 6 * 7 → 42 |
/ |
División | ver abajo |
Los cuatro operan sobre Int y Float. Lo interesante aparece cuando
mezclás los dos tipos.
Promoción Int ↔ Float¶
Si los dos operandos son Int, el resultado es Int. Si los dos son
Float, el resultado es Float. Si mezclás un Int y un Float,
el Int se promueve a Float y el resultado es Float:
print(2 + 3) // 5 (Int + Int = Int)
print(2.0 + 3.0) // 5.0 (Float + Float = Float)
print(1 + 1.0) // 2.0 (Int + Float = Float)
División: entera vs flotante¶
Esto es lo que más sorprende a quien viene de Python. En Fitz, /
entre dos Int da Int:
Para forzar división flotante, alcanza con que uno de los dos
operandos sea Float:
print(10 / 3.0) // 3.3333333333333335
print(10.0 / 3) // 3.3333333333333335
print(10.0 / 3.0) // 3.3333333333333335
El comportamiento es el de Rust (y C, y Go): la división se "porta
como" el tipo de sus operandos. Si querés el comportamiento de Python
3 (/ siempre flotante, // entera), por ahora tenés que ser
explícito convirtiendo uno de los lados.
Aviso de IEEE 754: vas a ver
3.3333333333333335y no3.3333333333333333. No es un bug — losFloatde 64 bits no pueden representar exactamente decimales periódicos. Es el mismo "error" que vas a ver en Python (0.1 + 0.2), JavaScript y Rust.
División por cero¶
Dividir por cero, en Int o Float, corta la ejecución con un error
explícito:
No hay NaN ni Infinity silenciosos; el intérprete prefiere parar y
avisar. (La línea sale como 0:0 por una limitación actual en cómo
guardamos posiciones de subexpresiones — lo vamos a mejorar.)
Unario negativo¶
- también funciona como prefijo para negar:
No hay un unario + redundante; tampoco hay incremento/decremento
(++, --). Si querés sumar 1 a una variable, escribís
x = x + 1.
Comparación¶
| Operador | Significado |
|---|---|
< |
Menor que |
<= |
Menor o igual |
> |
Mayor que |
>= |
Mayor o igual |
Devuelven Bool. Sirven para números (Int y Float, con la misma
promoción que la aritmética) y para strings, que se comparan
lexicográficamente carácter por carácter:
Igualdad¶
| Operador | Significado |
|---|---|
== |
Igual a |
!= |
Distinto de |
La comparación de igualdad tiene una sola coerción: entre Int y
Float numéricamente equivalentes. Todo el resto compara primero el
tipo:
print(1 == 1) // true
print(1 == 1.0) // true — coerción Int↔Float
print(1 == "1") // false — tipos distintos, sin coerción
print(true == 1) // false — Bool y Int son tipos distintos
print(null == null) // true
print(null == 0) // false
Es a propósito: nada de la maldad histórica de == en JavaScript.
Si dos valores tienen tipos distintos (salvo Int/Float), son
distintos.
Precedencia¶
De más fuerte a más débil (lo que toca en este capítulo):
- Unario
- *,/+,-<,<=,>,>===,!=
Y como siempre, los paréntesis ganan:
Los lógicos and / or también participan de la precedencia (van
debajo de igualdad). Los vemos en el capítulo 6.
Módulo %¶
% calcula el resto de la división entera. Solo válido entre Int:
print(10 % 3) // 1
print(12 % 4) // 0
print(7 % 2) // 1 — útil para detectar pares/impares
// En condición:
let n = 8
if (n % 2 == 0) {
print("par")
}
Semántica euclidean (mismo signo del divisor, igual que
Python — distinto del % Rust que es truncate-toward-zero):
n % 0 es error runtime (división por cero), no infinity.
El mismo binario producido por fitz build tiene el mismo check
y emite "división por cero" en lugar de panic crudo de Rust.
Asignación compuesta¶
+=, -=, *=, /= aplican la operación al destino sin tener
que escribirlo dos veces. Se desugar en el parser a
target = target <op> rhs, así que valen sobre cualquier destino
de asignación: identificador, campo, índice:
let total = 0
total += 5 // total = total + 5
total -= 2
total *= 3
total /= 2
let xs = [10, 20, 30]
xs[0] += 100 // xs[0] = xs[0] + 100
type Counter { count: Int = 0 }
let c = Counter {}
c.count += 1 // c.count = c.count + 1
Patrón típico: acumular adentro de un loop:
Operadores bit-a-bit¶
Seis operadores bit-a-bit sobre Int. Combinan natural con
literales hex/binario/octal para máscaras de bits, flags y
manipulación de bytes.
| Operador | Aridad | Función |
|---|---|---|
& |
Binario | AND bit-a-bit |
\| |
Binario | OR bit-a-bit |
^ |
Binario | XOR bit-a-bit |
<< |
Binario | Shift left |
>> |
Binario | Shift right (aritmético) |
~ |
Unario | NOT bit-a-bit |
let raw: Int = 0xABCD
let lo: Int = raw & 0xFF // 0xCD = 205
let hi: Int = (raw >> 8) & 0xFF // 0xAB = 171
let flags: Int = 0b0001 | 0b0010 // 0b0011 = 3
let toggled: Int = 0xFF ^ 0xAA // 0x55 = 85
let doubled: Int = 1 << 4 // 16
let inverted: Int = ~0 // -1 (i64 con signo)
Precedencia (paralelo a Python/C): | < ^ < & < <</>>.
Sin paréntesis, a | b & c se parsea como a | (b & c). El
unario ~ tiene la misma precedencia que - (negación numérica).
Reglas estrictas:
- Ambos operandos deben ser Int. Float/Bool/Str → error del checker.
- Shifts con RHS fuera de 0..64 → error de runtime.
Encaja natural con format specs para debug visual:
Ver examples/guide/04b-operadores-bit.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Lo que todavía no anda¶
%=(módulo compuesto) — sub-paso menor si aparece presión.%sobreFloat(la ambigüedad entrefmodyrem_euclidrequiere decisión de diseño; sub-paso futuro si aparece presión).
Otros operadores soportados: módulo
%sobreIntcon semántica euclidean; operadores compuestos+=/-=/*=//=; operadores de bits&/|/^/<</>>/~(ver sub-sección de arriba); compuestos bit-a-bit&=/|=/^=/<<=/>>=y prefijos mayúscula0X/0B/0O(ver sub-sección de abajo).
Asignación compuesta bit-a-bit¶
Simetría natural con +=/-=/etc. Cinco ops compuestos:
let flags: Int = 0b0101
flags |= 0b0010 // setear bit: 0b0111
flags &= 0b1110 // clearear bit: 0b0110
flags ^= 0b0100 // toggle bit: 0b0010
flags <<= 2 // shift left 2: 0b1000
flags >>= 1 // shift right 1: 0b0100
Semántica: x &= y ≡ x = x & y. Solo sobre Int.
Además, los prefijos hex/bin/oct aceptan mayúscula (Python-compat):
Ver examples/guide/04c-asignacion-compuesta-bit.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Ejemplo completo¶
examples/guide/04-operadores.fitz:
print(2 + 3)
print(10 - 4)
print(6 * 7)
print(10 / 3) // 3 — división entera
print(10.0 / 3.0) // 3.3333333333333335
print(1 + 1.0) // 2.0
print(10 / 3.0) // 3.3333333333333335
print(-5)
x = 10
print(-x)
print(1 < 2)
print(3 >= 3)
print("a" < "b")
print(1 == 1)
print(1 == 1.0)
print(1 == "1")
print(null == null)
Salida:
En el próximo capítulo le entramos a los strings: concatenación,
interpolación de expresiones, y qué cosas se pueden meter adentro de
las llaves {...}.
5. Strings¶
Los strings ya aparecieron en cada capítulo anterior. Acá los miramos con detenimiento: cómo se combinan, cómo se interpolan, y qué caracteres especiales se pueden escapar.
Forma básica¶
Un string va entre comillas dobles:
Las comillas simples no se usan en Fitz — siempre comillas dobles. El contenido es UTF-8, así que emojis y acentos funcionan tal cual:
Por ahora un string vive en una sola línea. Si abrís comillas y hacés un Enter sin cerrarlas, el lexer corta con:
Error en línea N:M — String sin cerrar — salto de línea antes de la comilla de cierre
Sugerencia: Usá \n para incluir un salto de línea dentro del string
Los strings multilínea con """...""" están en la
especificación pero todavía no están implementados.
Concatenación con +¶
Para unir dos strings, usá +:
+ entre strings es estricto: solo Str + Str. Si intentás
sumar un string con un número, el intérprete corta:
Esta decisión es intencional: para juntar valores de tipos distintos,
la herramienta correcta es la interpolación, no +. Así evitamos
el lío histórico de JavaScript con "1" + 1 == "11".
Interpolación con {...}¶
Dentro de un string, cualquier cosa entre { y } se evalúa y se
convierte a texto. La forma más simple es interpolar un identificador:
Pero adentro de las llaves no estás limitado a identificadores — podés meter cualquier expresión:
count = 42
print("doble: {count * 2}") // doble: 84
print("dos más dos es {2 + 2}") // dos más dos es 4
Todos los tipos primitivos saben cómo se serializan a texto cuando se interpolan:
| Tipo | Cómo se interpola |
|---|---|
Str |
el contenido tal cual |
Int |
42 |
Float |
3.14 |
Bool |
true / false |
Null |
null |
count = 42
ratio = 3.14
active = true
nothing = null
print("count={count}, ratio={ratio}, active={active}, nothing={nothing}")
// count=42, ratio=3.14, active=true, nothing=null
Si la expresión adentro de {...} referencia algo que no existe,
el intérprete corta con un error explícito:
Escapes¶
Adentro de un string, el backslash (\) introduce un escape:
| Escape | Significa |
|---|---|
\n |
Salto de línea |
\t |
Tab |
\r |
Carriage return |
\" |
Comilla doble literal |
\\ |
Backslash literal |
\{ |
Llave de apertura literal (sin interpolar) |
\} |
Llave de cierre literal |
\0 |
NUL (U+0000) |
\b |
Backspace (U+0008) |
\xXX |
Byte ASCII (XX = 2 dígitos hex, 0x00-0x7F) |
\u{X...} |
Codepoint Unicode (1 a 6 dígitos hex, hasta U+10FFFF) |
print("línea 1\nlínea 2")
// línea 1
// línea 2
print("nombre:\tFitz")
// nombre: Fitz
print("dijo: \"hola\"")
// dijo: "hola"
print("barra: \\")
// barra: \
print("config: \{ port: 3000 \}")
// config: { port: 3000 }
// F9 — escapes extendidos
print("\x41 == A") // A == A
print("caf\u{00E9}") // café (BMP, 4 dígitos)
print("\u{2603}") // ☃ (snowman)
print("\u{1F600}") // 😀 (emoji suplementario, 5 dígitos)
El caso de las llaves es importante: si querés mostrar una llave
literal en un string (por ejemplo, JSON inline o un fragmento de
código), tenés que escaparla — si no, el intérprete intenta
interpretar lo que hay entre { y } como una expresión a
interpolar.
Reglas de los escapes extendidos (mini-tanda F9):
\xXXexige exactamente 2 dígitos hex. Valores >0x7F se rechazan al lex-time con sugerencia de usar\u{...}(paralelo a Rust). Para chars no-ASCII usá Unicode escapes.\u{...}acepta entre 1 y 6 dígitos hex, con o sin zero-padding. Surrogates (D800-DFFF) y valores >10FFFFse rechazan al lex-time. Lowercase y uppercase hex valen igual.\u{}vacío es error.- Si la
\aparece seguida de un char que no es escape válido (\q,\d, etc.), el lex-time aborta con mensaje claro.
examples/guide/05d-escapes-extendidos.fitz ejercita los 4 escapes nuevos + interpolación + triple-quote.
Strings multilínea con """..."""¶
Para mensajes largos, SQL, HTML, JSON inline, etc., usá triple-quote:
let sql = """
SELECT u.id, u.name, u.email
FROM users u
WHERE u.active = true
ORDER BY u.created_at DESC
"""
print(sql)
// La interpolación sigue funcionando adentro:
let name = "Fitz"
let bienvenida = """
Hola, {name}!
Esta es una bienvenida
de varias líneas.
"""
Características:
- Newlines literales son válidos (no necesitás \n).
- Comillas dobles aisladas se preservan literalmente. Solo
""" (tres seguidas) cierra el string. Útil para embeber
JSON con comillas:
\n, \t, \\,
\", \{, \}, \0, \b, \xXX, \u{...}).
- Interpolación {expr} funciona igual.
- Indentación: el contenido se preserva tal cual; si querés
recortar el indent común usá .replace(...) o construilo sin
indent.
Format specifiers¶
Los {...} de interpolación aceptan un :spec opcional después del
expr, con la misma sintaxis que Python:
let pi: Float = 3.14159
print("pi: {pi:.2f}") // "pi: 3.14"
let n: Int = 42
print("[{n:05d}]") // "[00042]"
print("[{n:>5}]") // "[ 42]"
let byte: Int = 255
print("hex: {byte:#x}") // "hex: 0xff"
let big: Int = 1000000
print("con coma: {big:,}") // "con coma: 1,000,000"
let ratio: Float = 0.42
print("pct: {ratio:.1%}") // "pct: 42.0%"
Gramática del spec: [[fill]align][sign][#][0][width][grouping][.precision][type].
| Componente | Valores | Función |
|---|---|---|
fill |
cualquier char | Caracter de relleno. Solo válido si va con align. |
align |
< > ^ = |
Alineación left / right / center / after-sign. |
sign |
+ - |
Signo: siempre / solo negativos / espacio en positivos. |
# |
(flag) | Alternate form (0x en hex, etc.). |
0 |
(flag) | Zero-pad (atajo para fill='0'). |
width |
dígitos | Ancho mínimo total. |
grouping |
, o _ |
Separador de miles. |
.precision |
.N |
Decimales (Float) o longitud máx (Str). |
type |
b/c/d/e/E/f/F/g/G/o/s/x/X/% |
Forma de presentación. |
Compatibilidad por type:
- f/F/e/E/g/G/% — Float (Int promueve transparente).
- d/b/o/x/X/c — Int estricto.
- s — cualquier tipo (vía Display).
- Sin type — Display por default.
El checker valida la compatibilidad: {x:.2f} con x: Str da error
de tipo antes de runtime.
Subset compilable con fitz build (subset que mapea directo a
format! de Rust): precisión Float, width/zero-pad de Int, alineación,
fill custom, sign, alternate (#), hex/binario/octal. Solo fitz
run (Rust no tiene equivalente nativo): grouping (,/_),
percent (%), exponente (e/E), general (g/G), char (c).
El codegen emite error claro citando fitz run como workaround.
Ver examples/guide/05b-format-specs.fitz
(subset compilable, validado bit-a-bit fitz run ↔ fitz build) y
examples/guide/05c-format-specs-advanced.fitz
(full Python, solo fitz run).
Lo que todavía no anda¶
- Comillas simples como alternativa a las dobles.
Features completas de strings: strings multilínea
"""..."""con interpolación adentro y preservando newlines literales. Métodos.split(sep),.contains(s),.starts_with(s),.ends_with(s),.trim(),.replace(old, new),.repeat(n)— vivos enfitz runyfitz build(ver cap 13 + ejemplo 13c-metodos-extras.fitz). Format specifiers{x:.2f},{n:05d}, etc. — ver sub-sección "Format specifiers" arriba.
Ejemplo completo¶
examples/guide/05-strings.fitz:
name = "Fitz"
print(name)
greeting = "Hola, " + name
print(greeting)
print("Hola, {name}!")
count = 42
ratio = 3.14
active = true
nothing = null
print("count={count}, ratio={ratio}, active={active}, nothing={nothing}")
print("dos más dos es {2 + 2}")
print("doble: {count * 2}")
print("línea 1\nlínea 2")
print("nombre:\tFitz")
print("dijo: \"hola\"")
print("barra: \\")
print("config: \{ port: 3000 \}")
Salida:
Fitz
Hola, Fitz
Hola, Fitz!
count=42, ratio=3.14, active=true, nothing=null
dos más dos es 4
doble: 84
línea 1
línea 2
nombre: Fitz
dijo: "hola"
barra: \
config: { port: 3000 }
Bytes — datos binarios¶
Bytes es un primitivo paralelo a Str pero para datos binarios:
secuencias de bytes crudos, sin asumir UTF-8. Útil para protocolos
de red, archivos binarios, payloads opacos.
Literal: b"..." con escapes hex.
let a = b"hola" // bytes UTF-8 del string "hola"
let b = b"\x00\x01\xff" // bytes binarios
let vacio = b""
print(a) // b"hola"
print(b) // b"\x00\x01\xff"
Escapes soportados: \n, \r, \t, \0, \\, \", \xHH (byte
hex de 2 dígitos). NO admite interpolación {...} (los bytes
literales son fijos).
Métodos:
| Método | Devuelve | Qué hace |
|---|---|---|
b.len() |
Int |
Cantidad de bytes (NO chars). |
b.is_empty() |
Bool |
Atajo de .len() == 0. |
b.to_str() |
Result<Str> |
Decodifica como UTF-8. Ok(s) si es válido, Err(msg) si no. |
Constructor bytes(s: Str) -> Bytes: encodea un Str a UTF-8 bytes.
let b = bytes("hola") // b"hola"
let r = b"\xff".to_str() // Err("Bytes.to_str(): contenido no es UTF-8 válido en offset 0")
Diferencia con Str: Bytes("hola") != Str("hola") — son tipos
distintos aunque el contenido sea el mismo. ==/!= sobre Bytes
compara byte a byte.
Cuándo usar Bytes vs Str:
- Manipular texto (interpolar, indexar por char, métodos
.upper()/.split()/etc.) → usáStr. - Recibir/emitir bytes crudos (protocolos, archivos binarios,
uploads, payloads opacos) → usá
Bytes. - Tener un
Stry querés sus bytes UTF-8 →bytes(s). - Tener
Bytesque sabés que son UTF-8 →b.to_str()con manejo de error.
Ver el ejemplo completo en examples/guide/05e-bytes.fitz.
Con esto ya podés representar y combinar valores. En el próximo
capítulo arrancamos con la lógica: and, or con short-circuit, y
cómo se conecta con la comparación que vimos en el cap. 4.
6. Booleanos y lógica¶
Bool tiene exactamente dos valores: true y false. Los operadores
de comparación e igualdad del cap. 4 ya devuelven Bool. Acá los
combinamos con los operadores lógicos and y or.
and, or y xor¶
Como palabras, no como símbolos. (Fitz no usa && ni ||.) Operan
sobre Bool y devuelven Bool:
| Expresión | Resultado |
|---|---|
true and true |
true |
true and false |
false |
false and true |
false |
false and false |
false |
true or true |
true |
true or false |
true |
false or true |
true |
false or false |
false |
true xor true |
false |
true xor false |
true |
false xor true |
true |
false xor false |
false |
El uso real casi siempre es combinando con comparaciones:
xor es útil cuando querés "exactamente uno de los dos" —
equivale a a != b sobre Bool pero se lee más declarativo:
fn solo_uno(p: Bool, q: Bool) -> Bool {
return p xor q
}
print(solo_uno(true, false)) // true — solo uno
print(solo_uno(true, true)) // false — ambos
print(solo_uno(false, false)) // false — ninguno
xor y or comparten precedencia (más baja que and), y son
left-associative: a or b xor c parsea como (a or b) xor c.
Short-circuit¶
Igual que Python, JavaScript y Rust, los operadores and y or en
Fitz hacen short-circuit:
a or b— siaya estrue,bno se evalúa.a and b— siaya esfalse,bno se evalúa.
xor NO hace short-circuit: necesita ambos lados para saber el
resultado (paralelo a las operaciones aritméticas).
Esto importa cuando el lado derecho tiene side effects, o cuando hace algo costoso:
fn ruido() {
print("¡me llamaron!")
return true
}
print(true or ruido()) // imprime solo "true"
print(false and ruido()) // imprime solo "false"
print(true and ruido()) // imprime "¡me llamaron!" y luego "true"
En la práctica, esto te deja escribir cosas como
x != null and x > 0 sin preocuparte por evaluar x > 0 cuando x
es null (cuando lleguen los tipos custom y la nullabilidad en
serio).
Tipo estricto: no hay "truthy" / "falsy"¶
A diferencia de Python o JavaScript, en Fitz and y or solo
trabajan con Bool, y los condicionales también. Si pasás otro
tipo, el intérprete corta:
print(1 and 2)
// Error en línea 0:0 — operando izquierdo de `and` debe ser Bool, no `Int`
if 1 { print("ups") }
// Error en línea 0:0 — la condición de `if` debe ser Bool, no `Int`
Lo mismo aplica a while y demás condicionales (los vemos en el cap.
8). Si querés un check sobre un número, hacelo explícito:
Es más texto, pero también más claro: n != 0 dice exactamente qué
estás chequeando, sin reglas mentales sobre qué cuenta como "falsy".
Precedencia completa¶
Extendiendo la tabla del cap. 4, la lista total de precedencia (de más fuerte a más débil) es:
- Unario
- *,/+,-<,<=,>,>===,!=andor
Eso significa que true or false and false se evalúa como
true or (false and false) → true. Si querés invertir el orden,
usá paréntesis:
Como regla cómoda: si tenés que pensarlo dos veces, ponele paréntesis. Cuesta dos caracteres y le ahorra trabajo a quien lea el código.
Negación con not¶
not <expr> invierte un Bool. Útil para hacer condiciones más
legibles cuando el opuesto es lo que querés expresar:
let active = false
if (not active) {
print("inactivo")
}
// not se aplica antes que las comparaciones, así que:
let x = 5
if (not x == 10) { // ← (not x) == 10 — type error si x:Int
print("no es 10")
}
// Para negar el resultado de una comparación, usá paréntesis:
if (not (x == 10)) { // ← negación del Bool resultado
print("no es 10")
}
not exige Bool estricto (no truthy/falsy). not 0 o
not "" son type error — consistente con la decisión de
diseño "sin truthy/falsy" del lenguaje. El checker estático lo
caza antes de correr.
Funciona idéntico en fitz run y fitz build: el codegen emite
! Rust nativo.
Lo que todavía no anda¶
- XOR lógico — no hay
xor. Si lo necesitás puntualmente,a != bsobre dosBoolte da el mismo resultado.
El operador
not <expr>exigeBoolestricto — sin truthy/falsy.
Ejemplo completo¶
examples/guide/06-logica.fitz:
print(true and true)
print(true and false)
print(false or true)
print(false or false)
age = 20
print(age >= 18 and age < 65)
print(age < 13 or age >= 65)
fn ruido() {
print("¡me llamaron!")
return true
}
print(true or ruido())
print(false and ruido())
print(true and ruido())
print(true or false and false)
print((true or false) and false)
Salida:
Con and/or y comparación ya tenés todo para escribir condiciones
ricas. En el próximo capítulo arrancamos a usarlas: if, else if,
else, y if como expresión.
7. if / else¶
if en Fitz cumple dos roles a la vez: es una sentencia (decide
qué bloque ejecutar) y también una expresión (produce un valor).
Esto último es lo que más probablemente te va a sorprender si venís
de Python.
Como sentencia¶
La forma básica:
Las llaves son obligatorias incluso para una sola sentencia. La
condición tiene que ser Bool — no hay truthy/falsy (cap. 6).
Para encadenar más casos, usá else if:
score = 75
if score >= 90 {
print("A")
} else if score >= 80 {
print("B")
} else if score >= 70 {
print("C")
} else {
print("F")
}
else es opcional. Si solo querés actuar en el caso verdadero, lo
omitís:
Como expresión¶
Acá viene lo interesante. En Fitz, if también es una expresión que
evalúa a un valor: el de la última expresión del bloque elegido.
Eso te deja escribir:
Es la misma sintaxis que la sentencia — la diferencia es que ahora
está al lado derecho de un = (o en cualquier lugar donde se espera
un valor). Si venís de TypeScript, es el equivalente al operador
ternario cond ? a : b, pero más legible y sin tener que aprender
una segunda sintaxis. Si venís de Rust, es exactamente el mismo
patrón.
Funciona con else if también:
n = 0
sign = if n > 0 { "positivo" } else if n < 0 { "negativo" } else { "cero" }
print(sign) // cero
Bloques con varias sentencias¶
El bloque puede tener varias sentencias. El valor del if es la
última expresión del bloque elegido:
total = if true {
let a = 10
let b = 20
a + b // ← esta es la última expresión
} else {
0
}
print(total) // 30
Esto se parece a cómo funcionan los bloques en Rust. Las sentencias intermedias hacen su trabajo (asignaciones, prints, llamadas), y la última expresión es lo que "sale" del bloque.
Sin else como expresión¶
Si usás if como expresión sin else y la rama no se cumple, el
valor es null:
Funciona, pero suele ser confuso de leer. Recomendación: cuando uses
if como expresión, escribí siempre el else. Si solo te interesa
el efecto cuando la condición es verdadera, usalo como sentencia y
listo.
Scope (recordatorio)¶
Como vimos en el cap. 3, los bloques de if no crean scope nuevo.
Una variable definida adentro sigue viva afuera:
Es comportamiento estilo Python, no estilo Rust. Si en el futuro esto trae problemas reales lo reconsideramos, pero por ahora simplifica mucho el lenguaje.
Ejemplo completo¶
age = 20
if age >= 18 {
print("mayor")
} else {
print("menor")
}
score = 75
if score >= 90 {
print("A")
} else if score >= 80 {
print("B")
} else if score >= 70 {
print("C")
} else {
print("F")
}
active = true
status = if active { "on" } else { "off" }
print(status)
n = 0
sign = if n > 0 { "positivo" } else if n < 0 { "negativo" } else { "cero" }
print(sign)
total = if true {
let a = 10
let b = 20
a + b
} else {
0
}
print(total)
Salida:
Con if ya podés decidir. En el próximo capítulo le sumamos repetir:
while, loop, break y continue.
8. Loops¶
Para repetir código en Fitz hay tres construcciones: while, loop y
for ... in. Este capítulo cubre las dos primeras; for necesita
listas y rangos, así que vive en el capítulo 9
junto con las colecciones sobre las que itera.
while¶
Repite el bloque mientras la condición sea true. Igual que con if,
la condición tiene que ser Bool (sin truthy/falsy):
Salida:
Si la condición arranca en false, el bloque no se ejecuta nunca. Y
si nunca cambia a false adentro, tenés un loop infinito — el
intérprete no te va a salvar de eso.
loop¶
Loop infinito, sin condición. Pensado para los casos donde la salida
es por break, no por condición de entrada:
Es lo que en otros lenguajes escribirías como while true { ... },
con un nombre propio. La diferencia es semántica: cuando escribís
loop, le estás avisando al lector "esto sale por break, no por
condición".
break y continue¶
breakcorta el loop y sigue después del bloque.continuesalta al inicio de la próxima iteración.
Salida:
Notá que en este caso j = j + 1 va antes del continue. Si lo
ponés después, te quedás colgado: la iteración salta y j nunca
incrementa.
Si usás break o continue fuera de un loop, el intérprete corta
con un error explícito:
Loops anidados¶
break y continue actúan sobre el loop más interno que los
contiene. No hay labels todavía (estilo Rust) para romper varios
niveles:
fila = 0
while fila < 2 {
col = 0
while col < 3 {
if col == 2 {
break // rompe solo el while interno
}
print("({fila},{col})")
col = col + 1
}
fila = fila + 1
}
Salida:
Si necesitás cortar el loop externo desde adentro, lo más limpio hoy es mover una bandera:
done = false
fila = 0
while fila < 5 and done == false {
col = 0
while col < 5 {
if col == 3 and fila == 2 {
done = true
break
}
col = col + 1
}
fila = fila + 1
}
(Cerrado: ahora podés usar labels para romper varios niveles — ver la sub-sección "Labels en break/continue" abajo.)
Loop como expresión con valor¶
loop { ... } también funciona como expresión: el valor del primer
break <v> que dispara es el valor de la expresión. Útil para
retry patterns y polling.
let counter = 0
let result = loop {
counter = counter + 1
if counter == 5 {
break counter * 10
}
}
print(result) // 50
// break sin valor → Null
let nothing = loop { break }
loop sigue funcionando como statement (sin retorno) para
compatibilidad con código existente.
Labels en break / continue¶
Para escapar de un loop externo desde un loop anidado, declarás
un label 'name: antes del loop y lo referenciás en break o
continue:
'outer: for i in 0..5 {
for j in 0..5 {
if i * j == 6 {
break 'outer // sale de los DOS for
}
}
}
'main: while (running) {
if exhausted {
break 'main
}
}
// Con loop como expresión + label + valor:
let result = 'top: loop {
loop {
if cond {
break 'top 42 // sale de los dos loops, valor = 42
}
}
}
Sintaxis paralela a Rust. El label se valida en el lexer
(apóstrofe + identificador) y en el codegen se emite Rust
nativo ('name: loop {}, break 'name expr).
Ver examples/guide/08b-loops-avanzados.fitz para el ejemplo completo.
Lo que todavía no anda¶
- (todos los faltantes históricos de loops ya están cubiertos — break/continue con labels, loop como expresión con valor, etc.)
Ejemplo completo¶
i = 0
while i < 3 {
print("i = {i}")
i = i + 1
}
n = 0
loop {
if n >= 3 {
break
}
print("n = {n}")
n = n + 1
}
j = 0
while j < 5 {
j = j + 1
if j == 3 {
continue
}
print("j = {j}")
}
fila = 0
while fila < 2 {
col = 0
while col < 3 {
if col == 2 {
break
}
print("({fila},{col})")
col = col + 1
}
fila = fila + 1
}
Salida:
En el próximo capítulo vamos a las colecciones — listas, mapas y
rangos — y al for ... in que itera sobre ellas. Después de eso, el
capítulo de match cierra la parte de control de flujo.
9. Listas, mapas y rangos¶
Hasta acá manejamos valores sueltos. En este capítulo entran las tres
estructuras que te dejan agrupar muchos valores y recorrerlos:
listas, mapas y rangos. Y con ellas llega for ... in,
la forma natural de iterar.
Listas¶
Una lista es una secuencia ordenada de valores. Se escribe entre corchetes, separados por coma:
Los elementos pueden ser de cualquier tipo, incluso mezclados:
La lista vacía es []:
Acceso por índice¶
xs[i] devuelve el elemento en la posición i, base 0:
Si te pasás del tamaño, el intérprete corta:
Los índices negativos al estilo Python (xs[-1] para el último)
no están soportados todavía — dan error explícito. Si necesitás
el último elemento, hacé xs[len(xs) - 1].
Mapas¶
Un mapa asocia claves con valores. Se escribe entre llaves, separando
clave y valor con ::
Las claves típicamente son strings, pero podés usar cualquier valor
comparable como clave (Int, Bool, etc.). El orden de inserción se
preserva: si insertaste "a" antes que "b", así se imprime.
Para leer un valor, usá la misma sintaxis de indexing:
Si la clave no existe, el intérprete corta:
El mapa vacío es {}:
Rangos¶
Un rango representa una secuencia de enteros entre dos extremos. Se escribe con dos puntos:
El extremo derecho es exclusivo: 0..5 representa 0, 1, 2, 3, 4
(cinco valores). Es la misma convención que Rust o que range(n) de
Python. Si el rango va al revés (10..0), tiene longitud cero —
nunca itera.
Rangos inclusivos con ..=:
r2 = 0..=5
print(len(r2)) // → 6 — incluye el 5
for i in 0..=10 {
print(i) // 0, 1, ..., 10 (11 iteraciones)
}
..= también funciona en patrones de match:
fn nota(score: Int) -> Str {
return match score {
0..=59 => "F"
60..=69 => "D"
70..=79 => "C"
80..=89 => "B"
90..=100 => "A" // matchea 90 hasta 100 inclusive
_ => "fuera de rango"
}
}
print(nota(100)) // → "A"
Los rangos son valores como cualquier otro: podés asignarlos, pasarlos a funciones, y compararlos por igualdad. Pero su uso natural es iterar, que viene ahora.
Step con step_by(n) — útil para saltear elementos al iterar,
materializa el rango con paso:
let evens: List<Int> = (0..10).step_by(2)
print(evens) // [0, 2, 4, 6, 8]
// step_by también funciona sobre rangos inclusivos.
let by_three: List<Int> = (0..=20).step_by(3)
print(by_three) // [0, 3, 6, 9, 12, 15, 18]
n debe ser Int > 0 — el runtime corta con error claro si pasás
0 o negativo. Ver cap 13 para los métodos completos sobre Range
(enumerate/zip/chain/len/step_by).
for ... in¶
for var in iterable { body } recorre los elementos de la lista o
los enteros del rango, una iteración por valor. La variable var se
redefine en cada vuelta:
for x in [10, 20, 30] {
print(x)
}
// → 10
// → 20
// → 30
for i in 0..3 {
print(i)
}
// → 0
// → 1
// → 2
break y continue funcionan igual que en while:
total = 0
for i in 0..10 {
if i == 5 {
break
}
total = total + i
}
print(total) // → 0 + 1 + 2 + 3 + 4 = 10
La variable de iteración persiste después del loop (misma política que el resto de los bloques de Fitz — las variables no crean scope nuevo):
Si querés iterar varias dimensiones, anidás:
Iterar Maps con destructuring¶
for también itera sobre Map<K, V>, produciendo un par (k, v)
por cada iteración. El patrón canónico es destructurar en el binding:
let inventario: Map<Str, Int> = {"manzanas": 5, "peras": 3}
for (fruta, cantidad) in inventario {
print("{fruta}: {cantidad}")
}
El orden de iteración es el orden de inserción del Map (Fitz preserva
inserción order). El for ... in toma un snapshot del Map antes de
iterar, así que mutar el Map durante el loop no afecta la iteración.
Para casos donde querés ignorar un campo (o todo el elemento), usá _:
let suma: Int = 0
for (_, v) in inventario { // solo valores
suma = suma + v
}
for _ in 0..5 { // solo contar
print("tick")
}
El _ no bindea nada — útil con for _ in 0..N para "repetir N veces".
Si necesitás el par como Tuple (sin destructurar), usá un Ident
solo (solo fitz run; el codegen exige tuple pattern):
Ver examples/guide/09e-for-map.fitz
para el ejemplo completo, validado bit-a-bit fitz run ↔ fitz build.
Patrón de rango en match (adelanto)¶
Los rangos también se pueden usar como patrones en match, para
clasificar un Int en bandas:
Esto se ve en detalle en el próximo capítulo.
Anidación¶
Listas, mapas y rangos se combinan libremente:
matriz = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
print(matriz[1][2]) // → 6
usuarios = [
{"name": "Ana", "age": 30},
{"name": "Beto", "age": 25},
]
print(usuarios[0]["name"]) // → Ana
Asignación a índice¶
Mutación posicional con xs[i] = v y m["k"] = v:
let xs = [1, 2, 3, 4]
xs[0] = 99 // replace
xs[2] = 77
print(xs) // [99, 2, 77, 4]
let m = {"a": 1, "b": 2}
m["a"] = 10 // replace existente
m["c"] = 3 // insert nuevo al final (preserva insertion order)
print(m) // {"a": 10, "b": 2, "c": 3}
Listas: el índice tiene que ser Int en rango [0, len).
Out-of-bounds o negativo → error runtime claro
(índice 5 fuera de rango (lista de tamaño 2)). Los binarios
producidos por fitz build emiten el mismo error (no panic
crudo de Rust).
Maps: la clave puede ser cualquier tipo hashable (Str, Int, Bool, Float, Null). Si la clave existe, se sobreescribe sin mover su posición; si no existe, se inserta al final. El insertion order se preserva igual que en literales.
Combinado con índice computado:
let nums = [1, 2, 3]
let i = 0
while (i < 3) {
nums[i] = nums[i] * 10
i = i + 1
}
print(nums) // [10, 20, 30]
Indexing y slicing¶
Listas y strings soportan índices negativos y slicing.
Índices negativos: xs[-1] es el último, xs[-2] el
penúltimo, etc. (igual que Python). Resolución: effective =
len + i. Out-of-range → error claro.
let xs = [10, 20, 30, 40, 50]
print(xs[-1]) // 50
print(xs[-2]) // 40
let s = "fitz"
print(s[0]) // "f"
print(s[-1]) // "z"
xs[-1] = 99 // asignación con negativo también
s[i] sobre Str devuelve un Str de un char (Fitz no tiene
tipo Char). Cuenta CHARS, no bytes (consistente con
s.len()).
Slicing: xs[a..b], xs[..b], xs[a..], xs[..],
xs[a..=b]. Funciona para listas y strings; devuelve siempre
una copia (mutar el slice no afecta al original).
let xs = [10, 20, 30, 40, 50]
print(xs[1..3]) // [20, 30]
print(xs[..2]) // [10, 20]
print(xs[3..]) // [40, 50]
print(xs[..]) // [10, 20, 30, 40, 50] (copia)
print(xs[1..=3]) // [20, 30, 40]
print(xs[-2..]) // [40, 50]
let s = "hola fitz"
print(s[..4]) // "hola"
print(s[-4..]) // "fitz"
Clamp silencioso para slices fuera de rango (estilo Python):
xs[100..] con len=5 → []. xs[..100] → copia entera. Si
start > end tras clamp → vacío.
Ver examples/guide/09b-indexing-slicing.fitz.
Lo que todavía no anda¶
- Métodos sobre listas y mapas —
xs.push(...),xs.map(...),m.get(...), etc. ya están vivos desde el paso 4 de Fase 3. Los ves en el capítulo 13. forsobre mapas: necesita el tipoPair/entry. Si lo intentás, el intérprete corta:
- Comprehensions (
[x * 2 for x in xs]). - Slicing con paso (
xs[::2]) — sin demanda concreta.
Tuples¶
Tipos compuestos heterogéneos de tamaño fijo, similares a Rust.
Útil para retornos múltiples y agrupar valores ad-hoc sin
declarar un type.
// Literal — la coma distingue tuple de paréntesis de agrupación.
let pair: (Int, Str) = (42, "fitz")
print(pair.0) // 42
print(pair.1) // fitz
// Tupla vacía (unit) y de 1 elemento:
let unit: () = ()
let single = (42,) // trailing comma obligatoria
// Retornos múltiples:
fn divmod(a: Int, b: Int) -> (Int, Int) {
return (a / b, a % b)
}
// Destructuring con `let`:
let (q, r) = divmod(17, 5)
print(q) // 3
print(r) // 2
// Wildcards y nesting:
let (a, _, c) = (10, 20, 30)
let ((x, y), z) = ((1, 2), 3)
// Tuple pattern en match:
fn clasif(p: (Int, Int)) -> Str {
return match p {
(0, 0) => "origen"
(0, _) => "eje Y"
(_, 0) => "eje X"
(a, b) => "({a}, {b})"
}
}
Limitaciones del MVP: - Tuples como llave de Map: no soportado por ahora.
Ver examples/guide/09c-tuples.fitz para el ejemplo completo.
let con sub-patterns ricos¶
Desde Lt, los let-tuple aceptan los mismos sub-patterns que los
match-arms: literales (Int/Float/Str/Bool/Null), rangos
(0..100, 0..=99), Or-patterns (1 | 2 | 3), Ok(name) y
Err(name). Si el value no matchea el pattern en runtime, el
programa paniquea con un mensaje claro (paralelo a Rust
let pat = val else { panic!() }).
// Literal Int como guard
let (1, x) = (1, 42)
print(x) // 42
// Literal Str
let ("ada", n) = ("ada", 7)
print(n) // 7
// Range
let (0..100, label) = (50, "rango ok")
print(label) // rango ok
// Ok-binding: extraer valor de Result
let (Ok(v), tag) = (Ok(99), "result")
print(v) // 99
print(tag) // result
// Anidamiento
let (Ok(v), (1, y)) = (Ok(42), (1, "deep"))
print(v) // 42
print(y) // deep
Caveat: el let falla en runtime si el value NO matchea
(let (1, x) = (2, 42) paniquea). Si el shape es incierto,
preferí match, que te permite cubrir el caso de no-match.
Ver examples/guide/09f-let-destructure-rico.fitz
para el ejemplo completo y validado bit-a-bit fitz run ↔
fitz build.
List comprehensions¶
Sintaxis compacta para construir listas derivadas. Azúcar sobre
los patrones .map() y .filter().map() — útil cuando el dato
viene directo de otro iterable y querés transformarlo/filtrarlo.
// Simple — el equivalente a `.map(fn(x) => x * 2)`.
let doublados: List<Int> = [x * 2 for x in [1, 2, 3]]
// → [2, 4, 6]
// Sobre Range — los rangos son iterables igual que listas.
let cuadrados: List<Int> = [n * n for n in 0..5]
// → [0, 1, 4, 9, 16]
// Con filter inline — `if cond` al final.
let pares: List<Int> = [n for n in 0..10 if n % 2 == 0]
// → [0, 2, 4, 6, 8]
// Expr compuesta — strings interpolados, llamadas, lo que sea.
let etiquetados: List<Str> = ["item-{i}" for i in 0..3]
// → ["item-0", "item-1", "item-2"]
Scope local del var (a diferencia del for ... in):
let i: Int = 100
let _: List<Int> = [i for i in 0..3] // el `i` de adentro es nuevo
print(i) // 100 — el original intacto
Las comprehensions abren un scope dedicado para el binding del
for. Esto es lo que hace Python y evita shadowear variables del
scope contenedor sin querer. El for ... in clásico de Fitz NO
tiene esta propiedad (su var queda visible afuera del loop) —
diferencia documentada pero intencional.
Cobertura completa:
- Múltiples for clauses (cartesian product):
[x*y for x in xs for y in ys]. El segundo iter puede depender
del binding del primero, así que [(i, j) for i in 1..=3 for j in 1..=i]
produce los pares triangulares.
- El var acepta tuple destructuring:
[a+b for (a, b) in pairs] o [a for (a, _) in pairs]. Paralelo
al for ... in ... destructure común.
- iter puede ser List<T> o Range (igual que for ... in).
- Filter inline if cond opcional al final — se aplica en el loop
más interno.
- Map comprehensions {key: value for var in iter} — produce
un Map<K, V> con last-write-wins en duplicados de key. Ver
cap 13 sub-sección dedicada para ejemplos.
Ver examples/guide/09d-comprehensions.fitz
para el ejemplo simple y
examples/guide/13p-mb4-y-comprehensions-extendidas.fitz
para multi-for + map comprehensions (validados bit-a-bit fitz run
↔ fitz build).
Features completas: índices negativos
xs[-1]para listas y strings + slicingxs[a..b],xs[..b],xs[a..],xs[..],xs[a..=b](con clamp silencioso). Tuples(T1, T2)con acceso.0/.1, destructuringlet (a, b) = ..., yPattern::Tupleen match. Comprehensions[expr for var in iter]con filter inline opcional. Ver las sub-secciones de arriba.Más features: asignación a índice — ver sección "Asignación a índice" arriba. Rangos inclusivos (
0..=10). Ahorafor i in 0..=10itera 0..10 inclusive, ymatch n { 0..=100 => ... }matchea ambos extremos.
Ejemplo completo¶
examples/guide/09-listas-mapas.fitz:
nums = [1, 2, 3, 4, 5]
print(nums)
print("primer: {nums[0]}")
print("último: {nums[4]}")
print("cantidad: {len(nums)}")
mezcla = [1, "dos", true, null, 3.14]
print(mezcla)
vacia = []
print("vacía: {vacia}, len: {len(vacia)}")
user = {"name": "Martín", "age": 43}
print(user)
print("nombre: {user[\"name\"]}")
items = {"primero": 1, "segundo": 2, "tercero": 3}
print(items)
r = 0..5
print(r)
print("cantidad: {len(r)}")
total = 0
for n in nums {
total = total + n
}
print("suma de nums: {total}")
print("contando:")
for i in 0..5 {
print(" {i}")
}
fn clasificar(n) {
return match n {
0..10 => "chico"
10..100 => "mediano"
100..1000 => "grande"
_ => "fuera"
}
}
print(clasificar(3))
print(clasificar(42))
print(clasificar(500))
print(clasificar(99999))
En el próximo capítulo cerramos control de flujo con match:
patrones literales, binding por identificador, _, y los patrones
de rango que recién vimos en acción.
10. Match¶
match compara un valor contra una serie de patrones y ejecuta el
primero que coincide. Es la herramienta natural cuando estás haciendo
un else if tras otro, todos comparando contra la misma variable.
Forma general¶
El => (fat arrow) separa el patrón de lo que se ejecuta cuando
coincide. Cada brazo termina con un newline; no hace falta coma.
Patrones literales¶
Los cinco tipos primitivos se pueden usar tal cual como patrón:
status = "active"
match status {
"active" => print("activo")
"inactive" => print("inactivo")
_ => print("desconocido")
}
Funciona con Int, Float, Str, Bool y Null. Los enteros y
floats negativos también:
Y con null:
Binding con identificador¶
Un patrón que es un identificador "captura" el valor y lo deja disponible dentro del brazo. Es decir, funciona como un default que además le pone nombre al valor:
Importante: como el identificador siempre matchea, tiene que ir al final. Si lo ponés primero, todos los brazos siguientes son código muerto — y el intérprete no te avisa de eso todavía.
Wildcard _¶
_ es el "no me importa el valor". Equivale al binding pero sin
darle nombre. Convención: usá _ cuando no necesitás el valor en el
brazo, y un identificador cuando sí.
Como expresión¶
Igual que if, match también es una expresión:
day = 3
name = match day {
1 => "lunes"
2 => "martes"
3 => "miércoles"
_ => "otro día"
}
print(name) // miércoles
El valor de cada brazo es el de la expresión a la derecha del =>.
Si querés un bloque, podés usar llaves (mismo patrón que en if):
¿Qué pasa si nada coincide?¶
Si ningún brazo coincide, el intérprete corta:
fitz check exige exhaustividad cuando el valor matcheado tipa
como Result<T> — vas a ver un error si te
falta el caso Ok o el caso Err (a menos que tengas un _ o un
binding final que actúe como catch-all). Para los demás tipos
(Int, Str, etc.), la exhaustividad todavía es tu
responsabilidad y la regla práctica sigue siendo: si el conjunto de
valores posibles no está acotado, terminá siempre con _.
Cuándo usar match vs if / else if¶
Como guía:
- Si estás comparando el mismo valor contra distintas constantes,
matches más legible. - Si tus condiciones son diferentes entre sí (rangos, expresiones
con varias variables, llamadas a función), seguí con
if/else if.
Ejemplo donde match claramente gana:
// Con if / else if:
if status == "active" {
print("activo")
} else if status == "inactive" {
print("inactivo")
} else if status == "pending" {
print("pendiente")
} else {
print("desconocido")
}
// Con match:
match status {
"active" => print("activo")
"inactive" => print("inactivo")
"pending" => print("pendiente")
_ => print("desconocido")
}
Patrones de rango¶
Para los Int, podés usar un rango como patrón. Matchea si el valor
es Int y cae adentro del rango (con la cota derecha exclusiva,
igual que el operador ..):
fn clasificar(n) {
return match n {
0..10 => "chico"
10..100 => "mediano"
_ => "grande"
}
}
print(clasificar(5)) // → chico
print(clasificar(10)) // → mediano (10 no entra en 0..10)
print(clasificar(500)) // → grande
Los extremos negativos también son válidos:
match temperatura {
-50..0 => print("bajo cero")
0..10 => print("frío")
10..25 => print("templado")
_ => print("calor")
}
Si el valor no es Int (por ejemplo, un Float), el patrón de
rango simplemente no matchea, y se evalúa el siguiente brazo.
Ok(x) / Err(e) — un adelanto¶
Si el valor matcheado es un Result, los patrones Ok(v) y Err(e)
matchean cada variante y bindean el inner:
Lo cubrimos en detalle en el próximo capítulo.
Or-patterns pat1 | pat2 | pat3¶
Cuando varios patrones llevan al mismo body, escribilos separados
por | en un solo arm. El arm matchea si cualquiera de los
sub-patrones matchea:
match dia {
"lun" | "mar" | "mie" | "jue" | "vie" => "laboral"
"sab" | "dom" => "fin de semana"
_ => "?"
}
Funciona con literales, rangos y wildcards de Result:
match n {
0 | 1 | 2 => "muy chico"
3..=10 | 100..=1000 => "medio o muy grande"
_ => "otro"
}
match r {
Ok(_) | Err(_) => "cualquier resultado"
}
Restricción: los sub-patrones de un or-pattern no pueden
bindear (igual que Rust). 1 | x => ... y Ok(v) | Err(_) =>
... son errores de parser; usá _ o desdoblá el arm si necesitás
binding distinto por caso.
Guards pat if cond =>¶
Un guard es una condición extra que se chequea después de que
el pattern matchee. El arm matchea si el
pattern matchea Y el guard evalúa a true:
match age {
a if a < 0 => "edad inválida"
a if a < 13 => "niño"
a if a < 18 => "adolescente"
a if a < 65 => "adulto"
_ => "mayor"
}
El binding del pattern es visible adentro del guard:
match r {
Ok(v) if v > 0 => "positivo"
Ok(v) if v == 0 => "cero"
Ok(_) => "negativo"
Err(_) => "error"
}
Exhaustividad: los arms con guard NO cuentan para la
exhaustividad de Result — el guard puede ser false y dejar el
match incompleto. Si todos tus arms tienen guard, el checker te
exige un catch-all (_ o ident) al final:
// Error de checker: match no exhaustivo, falta el caso Err
let s = match r { Ok(_) if true => "x" }
Tuple patterns con sub-patterns ricos¶
Adentro de un tuple pattern ((a, b)), los sub-patterns admiten
Str literal, Range, y Or-pattern:
fn clasif(p: (Str, Int)) -> Str {
return match p {
("ada", 1) => "ada uno", // Str + Int literal
("ada", n) => "ada otro: {n}", // Str literal + bind
(name, 1 | 2) => "{name} chico", // Bind + Or-pattern
(name, 0..10) => "{name} dig", // Bind + Range
(name, n) if n > 100 => "{name} grande: {n}",
(name, n) => "{name}: {n}"
}
}
Funciona bit-a-bit en fitz run y fitz build. El codegen
sintetiza nombres únicos __s_<n>/__n_<n>/__or_v_<n> por
slot del tuple para que dos sub-patterns con guard no choquen.
Combinable con guards explícitos (if cond) en el mismo arm,
con paréntesis anidados (((a, b), c)), y con cualquier
combinación de Ident/Wildcard/literal/Range/Or.
Ver examples/guide/10b-match-tuple-subpatterns.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Lo que todavía no anda¶
- Listas como patrón —
[head, ...rest], etc. Sin demanda concreta. - Exhaustividad para tipos no-Result —
fitz checkexige exhaustividad sobreResult<T>. Para Int, Str y otros tipos no acotados sigue siendo responsabilidad tuya cerrar con_.
Features de match completas: or-patterns (
p1 | p2), guards (pat if cond), tuple patterns(a, b), y tuple patterns con sub-patterns Str/Range/Or — todo soportado enfitz runyfitz build. Ver sub-secciones de arriba.
Ejemplo completo¶
status = "active"
match status {
"active" => print("activo")
"inactive" => print("inactivo")
_ => print("desconocido")
}
n = -1
match n {
0 => print("cero")
-1 => print("menos uno")
_ => print("otro")
}
val = 42
match val {
0 => print("cero")
x => print("otro: {x}")
}
day = 3
name = match day {
1 => "lunes"
2 => "martes"
3 => "miércoles"
_ => "otro día"
}
print(name)
match 3.14 {
3.14 => print("pi")
_ => print("otro")
}
match true {
true => print("sí")
false => print("no")
}
match null {
null => print("vacío")
_ => print("algo")
}
Salida:
Con esto cerramos la parte de control de flujo. En el próximo
capítulo entramos a las funciones: fn bloque, => flecha,
parámetros, recursión, y closures con captura léxica.
11. Funciones¶
Una función agrupa una serie de pasos bajo un nombre, recibe entradas
(parámetros) y devuelve un valor. En Fitz hay dos formas de
escribirlas, ambas con la misma palabra clave: fn.
Forma de bloque¶
La forma "larga" con llaves y return:
El bloque puede tener varias sentencias; el valor se devuelve con
return.
Forma flecha¶
Cuando el cuerpo es una sola expresión, podés usar => y saltearte
las llaves y el return:
Es el mismo concepto que las arrow functions de JavaScript o las
lambdas de Python: azúcar sintáctica para funciones cortas. La regla
mental: si la función es return expresion, escribilo con =>
expresion.
Parámetros y anotaciones¶
Los parámetros van entre paréntesis, separados por coma. Como con las
variables, podés anotarles el tipo, y también podés anotar el tipo de
retorno después de ->:
Las anotaciones se parsean pero todavía no se validan, igual que para variables (cap. 3). Cuando llegue el type checker (Fase 5), van a empezar a chequearse.
return con y sin valor¶
return corta la ejecución de la función y devuelve un valor:
Si llamás return sin expresión, devuelve null:
Y si la función termina sin pasar por ningún return, también devuelve
null:
Hoy no hay un tipo "void" separado — todo lo que no sea un valor
explícito es null.
Aridad estricta¶
Si llamás una función con una cantidad incorrecta de argumentos, el intérprete corta:
No hay argumentos opcionales, ni con valor por defecto, ni varargs todavía. Esa es deuda explícita; por ahora la firma manda.
Recursión¶
Una función puede llamarse a sí misma. El clásico factorial:
No hay tail call optimization todavía, así que recursiones muy profundas pueden agotar la pila — pero para los usos típicos no es problema.
Closures: capturar el scope externo¶
Una función definida adentro de otra ve las variables de la externa. Esto te permite "fabricar" funciones que llevan estado:
fn make_adder(x) {
fn add(y) => x + y // `x` viene del scope de make_adder
return add
}
add5 = make_adder(5)
print(add5(3)) // 8
add5 es una función que "recuerda" que x valía 5. Eso es un
closure: la función más su entorno léxico.
Captura por referencia¶
Importante saber esto, porque sorprende. El closure no congela el valor de la variable en el momento de definirse — guarda una referencia al entorno. Si la variable cambia después, el closure ve el nuevo valor:
Es el mismo comportamiento que en Python y JavaScript. Si querés
"capturar por valor", la práctica común es pasar la variable como
parámetro de la función fabricante (como en make_adder arriba): así
el valor queda fijado en cada llamada.
Funciones como valor¶
Una función es un valor más. La podés guardar en una variable, pasarla como argumento, devolverla desde otra función:
Funciones anónimas inline¶
Cuando una función es lo bastante chica como para no merecer un
nombre — típicamente una callback que pasás a otra función —
podés definirla "al vuelo" con la misma palabra clave fn, pero
sin nombre:
La forma de flecha es la típica, pero la forma de bloque también existe:
La utilidad real aparece cuando pasás la anónima como argumento:
Y se vuelven ergonómicas con los métodos sobre listas y mapas (cap.
13): xs.map(fn(n) => n * 2), users.find(fn(u) => u.id == 1).
Una anónima es una función como cualquier otra, así que también captura el scope donde se definió — lo mismo que con las nombradas.
Parámetros con default¶
Un parámetro puede tener un valor por defecto: si el caller no lo
provee, se usa ese valor. La sintaxis sigue de cerca a Python: nombre:
Tipo = expr.
fn greet(name: Str = "amigo") -> Str {
return "Hola, {name}"
}
print(greet()) // Hola, amigo
print(greet("Fitz")) // Hola, Fitz
Reglas:
- El default puede ser cualquier expresión válida (literales, idents, llamadas, etc.). Se evalúa cada vez que se llama la fn sin ese arg — no se cachea.
- Regla Python: una vez que un param tiene default, todos los
siguientes también. El parser rechaza
fn f(a = 1, b: Int)con error claro. - Funciona en
fntop-level, métodos custom sobretype(R.3) y métodos estáticos.fnanónimos (fn(x) => ...) lo aceptan sintácticamente pero el caso típico no lo necesita.
fn add(a: Int, b: Int = 10) -> Int {
return a + b
}
print(add(5)) // 15
print(add(5, 2)) // 7
fn make(prefix: Str = "x", n: Int = 3) -> Str {
return "{prefix}-{n}"
}
print(make()) // x-3
print(make("y")) // y-3
print(make("z", 5)) // z-5
Ver examples/guide/11b-default-params.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Varargs¶
Un param prefijado con ... es variádico: absorbe 0+ args extras
del call site y los recolecta en una List<T> adentro del body. Solo
el último param puede ser variádico; no se mezcla con default.
fn sum(...xs: Int) -> Int {
let total: Int = 0
for x in xs {
total = total + x
}
return total
}
print(sum()) // 0
print(sum(1, 2, 3)) // 6
print(sum(10, 20, 30, 40)) // 100
Funciona con params previos required:
Ver examples/guide/11c-varargs.fitz para el ejemplo completo.
Argumentos nombrados¶
En el call site, podés pasar args por nombre con la sintaxis name:
value. El nombre matchea el param de la fn, no la posición:
fn greet(name: Str = "amigo", greeting: Str = "Hola") -> Str {
return "{greeting}, {name}"
}
print(greet(name: "Fitz")) // Hola, Fitz
print(greet(greeting: "Hi")) // Hi, amigo
print(greet(greeting: "Hey", name: "Roy")) // Hey, Roy
Reglas:
- Los named args van después de los positionals (
greet("Fitz", greeting: "Hi")✓;greet(greeting: "Hi", "Fitz")✗). - El nombre debe corresponder a un param real de la fn.
- Sin duplicados: un mismo param no puede aparecer dos veces.
- No se combina con varargs (decisión MVP).
Útil sobre todo para fns con varios params opcionales, donde solo querés especificar uno:
fn config(host: Str = "127.0.0.1", port: Int = 3000, debug: Bool = false) -> Str {
return "{host}:{port}/{debug}"
}
print(config(port: 8080)) // 127.0.0.1:8080/false
print(config(debug: true)) // 127.0.0.1:3000/true
Ver examples/guide/11d-named-args.fitz para el ejemplo completo.
Lo que todavía no anda¶
- (nada significativo — default params, varargs y named args están
cubiertos. Los siguientes salen del scope de "polish de funciones":
trait-like polymorphism, herencia entre
types y operator overloading son decisiones grandes que requieren mini-fase dedicada.)
Features completas de funciones: default params, varargs, named args, métodos custom sobre
type— ver cap 13.
Ejemplo completo¶
examples/guide/11-funciones.fitz:
fn greet(name: Str) -> Str {
return "Hola, {name}!"
}
print(greet("Fitz"))
fn double(n: Int) -> Int => n * 2
print(double(21))
fn add(a: Int, b: Int) -> Int {
return a + b
}
print(add(2, 3))
fn nothing() {
let x = 1
}
print(nothing())
fn fact(n: Int) -> Int {
if (n <= 1) {
return 1
}
return n * fact(n - 1)
}
print(fact(5))
// Closure: la fn interna captura `x` del scope externo.
fn make_adder(x: Int) -> Fn(Int) -> Int {
return fn(y: Int) => x + y
}
let add5 = make_adder(5)
print(add5(3))
// Pasar funciones como argumento.
fn square(n: Int) -> Int => n * n
fn apply(f: Fn(Int) -> Int, x: Int) -> Int => f(x)
print(apply(square, 7))
// Funciones anónimas inline.
print(apply(fn(n: Int) => n * 10, 7))
let abs = fn(n: Int) -> Int {
if (n < 0) {
return -n
}
return n
}
print(abs(-5))
Sobre las anotaciones: con
fitz runson opcionales — el intérprete infiere desde el body. Confitz buildel codegen también infiere param + return desde call sites + body para los tipos primitivos y los compuestos comunes (Int/Float/Str/Bool/ Bytes/Nominal/List/Map/Result/Nullable). El ejemplo lleva anotaciones porque hacen el contrato explícito (mejor doc + diagnostics). El tipoFn(Int) -> Intdescribe una función que toma unInty devuelve unInt— es el tipo que tienensquare,make_adder(5), etc.
Salida:
Con funciones ya tenés todo lo necesario para escribir programas
completos. En el próximo capítulo entramos a type: cómo declarar
tus propios tipos, instanciarlos y acceder a sus campos.
12. Tipos con type¶
Hasta acá tu código modeló datos sueltos: ints, strings, listas, mapas. Para varios casos eso alcanza, pero apenas tenés "un usuario con id, nombre y email" empezás a pasar tres cosas relacionadas como si fueran independientes. Los tipos custom son la forma de nombrar esa relación: declarás una vez la forma de un dato, y después trabajás con instancias enteras.
Declarar un tipo¶
Un type define una estructura nueva — un conjunto de campos con
nombre y tipo:
Notas sobre la sintaxis:
- Los campos se separan con newline (o con coma, también). En el
resto del lenguaje, el
;es separator opcional entre stmts — newline lo cubre en casi todos los casos. - Cada campo declara
nombre: Tipo. - Un
?después del tipo lo marca como nullable (el campo puede sernull). Esto solo se permite hoy en campos detype, todavía no en anotaciones de variables sueltas. - Después de
=podés dar un valor por defecto.
Otro ejemplo, para algo tipo configuración:
Una declaración de type no crea ninguna instancia por sí sola — solo
registra la forma. Para crear datos concretos, usás un struct
literal.
Instanciar un tipo¶
Los campos van entre llaves, separados por coma o newline, con la
forma nombre: valor. El valor puede ser cualquier expresión:
El orden en el literal es libre — la instancia se ordena según la
declaración del type. Así dos instancias del mismo tipo se imprimen
igual, sin importar en qué orden las tipeaste:
let a = User { id: 1, name: "Fitz" }
let b = User { name: "Fitz", id: 1 }
// a y b son iguales (==) y se imprimen idéntico.
Acceder a campos¶
Con . sacás un campo de una instancia:
Funciona encadenado, si un campo es otra instancia:
type Order {
user: User
total: Int
}
let o = Order { user: u, total: 100 }
print(o.user.name) // Fitz
Si pedís un campo que no existe, el intérprete corta:
Defaults¶
Si un campo tiene un valor por defecto en la declaración, podés omitirlo al instanciar y se aplica el default:
Los defaults son expresiones y se evalúan cuando instanciás, no cuando declarás el tipo. Eso permite cosas como derivar el default de una variable del scope:
Defaults de tipos importados. Cuando un tipo se exporta a otro
archivo vía from foo import T, sus defaults pueden referenciar
consts u otros símbolos del módulo de origen sin que el importer los
tenga que re-importar. El loader los pre-evalúa en el env del módulo
de origen al cargarlo, así T {} desde el importer ya tiene los
valores resueltos.
// main.fitz
from foo import User // no hace falta `from foo import MAX`
let u = User {}
print(u.id) // 99
Campos nullables¶
Un campo declarado con Tipo? puede valer null. Si lo omitís al
instanciar, queda en null automáticamente:
También podés ponerlo explícito:
Si un campo no es nullable y no tiene default, omitirlo es error:
let u = User { id: 1 }
// Error en línea 0:0 — falta el campo `name` al instanciar `User`
// (no tiene default y no es nullable)
Y si pasás un campo que no está declarado en el tipo, también es error:
let u = User { id: 1, name: "x", color: "red" }
// Error en línea 0:0 — el tipo `User` no tiene un campo llamado `color`
Instancias en condiciones — usá paréntesis¶
Esto es la única fricción de sintaxis a tener en cuenta. Mirá:
¿Dónde termina la condición y dónde empieza el bloque del if? El
parser no tiene cómo adivinarlo sin lookahead arbitrario, así que
los struct literals no se permiten directamente como condición
de if, while, for o match. Si los tipeás ahí, el intérprete
te corta con un mensaje claro:
Error en línea 1:11 — los struct literals no se permiten directamente
en condiciones de if/while/for/match — envolvélo en paréntesis:
`(User { id: 1 })`
La solución es exactamente lo que el mensaje dice: envolver el struct literal en paréntesis.
Adentro de paréntesis, listas ([User { id: 1 }]), argumentos de
llamada (print(User { id: 1 })) e indexing (m[Key { id: 1 }]) los
struct literals están permitidos sin envolver — no hay ambigüedad
porque cada uno de esos contextos tiene un cierre propio.
Es el mismo trade-off que hacen Rust y Go.
Comparar instancias¶
== compara instancias estructuralmente: mismo tipo y mismos
valores en los mismos campos. La coerción Int↔Float que vimos en el
cap. 4 sigue valiendo dentro de los campos.
let a = User { id: 1, name: "Fitz" }
let b = User { id: 1, name: "Fitz" }
let c = User { id: 1, name: "Otro" }
print(a == b) // true
print(a == c) // false
Dos instancias de tipos distintos son siempre desiguales aunque tengan la misma forma:
type Admin { id: Int, name: Str }
let user = User { id: 1, name: "x" }
let admin = Admin { id: 1, name: "x" }
print(user == admin) // false
Imprimir instancias¶
print(u) muestra el formato canónico — nombre del tipo, llaves,
campos en orden de declaración:
Los strings adentro van con comillas (mismo criterio que listas y
mapas), para distinguir 1 de "1". La interpolación de un campo
suelto en un string sigue sin comillas, como cualquier Str:
Lo que todavía no anda¶
- Herencia / composición de tipos — un
typeno puede heredar campos de otro. Los structs son planos. Para compartir campos, por ahora repetirlos o anidarlos (type Order { user: User, ... }). - Trait-like polymorphism — no hay interfaces / traits. Si
necesitás polimorfismo, hoy es vía
matchsobre un enum tipotype Shape { ... }con un campo discriminador.
Features completas de
type: métodos custom sobretype— ver cap 13 con su propia sub-sección y ejemplos. Chequeo estático de anotaciones contra valores (let x: Int = "hola"falla enfitz check), genéricos compuestos en campos (List<Str>,Map<Str, User>, etc., validados por el checker), defaults que referencian otros símbolos del módulo de origen.
Ejemplo completo¶
type User {
id: Int
name: Str
email: Str?
active: Bool = true
}
type Config {
host: Str
port: Int = 3000
debug: Bool = false
}
let u = User { id: 1, name: "Fitz", email: "fitz@example.com" }
print(u.name)
print(u.email)
let c = Config { host: "localhost" }
print(c.port)
let anon = User { id: 2, name: "Anon" }
print(anon.email)
print(u)
print(c)
Salida:
Fitz
fitz@example.com
3000
null
User { id: 1, name: "Fitz", email: "fitz@example.com", active: true }
Config { host: "localhost", port: 3000, debug: false }
En el próximo capítulo entramos en métodos: la sintaxis receptor.metodo(args)
sobre listas, mapas, strings e instancias, y cómo funciona la
mutación en Fitz.
13. Métodos y mutación¶
Hasta acá las listas, los mapas y las strings los manejabas con
operaciones globales: len(xs), for n in xs, indexing con [].
Funciona, pero a veces lo natural es escribirlo en orden "objeto
primero": xs.len(), xs.map(...). Eso son métodos: funciones
que se llaman sobre un valor con la sintaxis receptor.metodo(args).
Por qué método y no función suelta¶
Misma operación, dos formas de escribirla:
let xs = [1, 2, 3, 4]
// Función global.
print(len(xs)) // 4
// Método sobre la lista.
print(xs.len()) // 4
Ambas formas valen y, en este caso, hacen lo mismo. La forma de
método brilla en cadenas: xs.map(...).filter(...) se lee de
izquierda a derecha, paso a paso, sin paréntesis anidados.
En Fitz, los métodos se resuelven por el tipo del receptor: hay
una tabla interna que sabe qué métodos tiene List, qué métodos
tiene Map, qué métodos tiene Str, etc. Si llamás un método que
no existe para ese tipo, el intérprete te corta con un mensaje
claro:
fitz check también valida los métodos built-in estáticamente:
tipos de argumentos, aridad, tipo del
receptor del callback en map/filter/find, y typos sobre
métodos inexistentes (xs.lenght()) los detectás sin tener que
ejecutar el programa.
Métodos de List¶
| Método | Qué hace |
|---|---|
push(v) |
Agrega v al final. Muta la lista. |
pop() |
Saca y devuelve el último. Muta la lista. |
map(fn) |
Aplica fn a cada elemento y devuelve una lista nueva. |
filter(fn) |
Devuelve una lista nueva con los elementos para los que fn da true. |
find(fn) |
Devuelve Ok(elemento) para el primero que matchea, o Err("no encontrado"). |
len() |
Cantidad de elementos. |
fn es cualquier función unaria. La forma más cómoda es la fn
anónima inline (cap. 11):
let xs = [1, 2, 3, 4]
let doblados = xs.map(fn(n) => n * 2)
print(doblados) // [2, 4, 6, 8]
let pares = xs.filter(fn(n) => n == 2 or n == 4)
print(pares) // [2, 4]
let tres = xs.find(fn(n) => n == 3)
print(tres) // Ok(3)
let veinte = xs.find(fn(n) => n == 20)
print(veinte) // Err("no encontrado")
push y pop mutan la lista; los demás devuelven datos nuevos
y dejan el receptor intacto.
let xs = [1, 2]
xs.push(3)
xs.push(4)
print(xs) // [1, 2, 3, 4]
let last = xs.pop()
print(last) // 4
print(xs) // [1, 2, 3]
Métodos de Map¶
| Método | Qué hace |
|---|---|
get(k) |
Devuelve Ok(valor) si la clave existe, o Err(...). |
has(k) |
true si la clave existe, false si no. |
keys() |
Lista con las claves, en orden de inserción. |
values() |
Lista con los valores, en orden de inserción. |
len() |
Cantidad de pares. |
filter(fn(k, v)) |
Map nuevo con los pares donde el callback devuelve true (Ex). |
map_values(fn(v)) |
Map nuevo: aplica fn a cada value, mantiene las keys (Ex). |
merge(other) |
Combina dos Map<K, V> (last-write-wins, paralelo a {**m, **other}) (Ex2). |
update(k, fn(v)) |
Map nuevo: aplica fn al value de k si existe, no-op si no (Up). |
keys_sorted() |
List<K> con las keys ordenadas; K ∈ {Int, Float, Str, Bool} (Mb2). |
entries() |
List<(K, V)> con los pares en orden de inserción; inversa de to_map (Mb3). |
invert() |
Map<V, K> con keys/values intercambiados; last-write-wins en values dups (Mb4). |
merge_with(o, fn) |
Map<K, V> con merge de other; el callback fn(V, V) -> V decide en conflicts (Mb6). |
with(k, v) |
Map nuevo con k → v; sobreescribe si k existe. Functional update (Mb7). |
let m = {"a": 1, "b": 2}
print(m.has("a")) // true
print(m.has("x")) // false
print(m.get("a")) // Ok(1)
print(m.get("x")) // Err("clave no encontrada: x")
print(m.keys()) // ["a", "b"]
print(m.values()) // [1, 2]
La diferencia entre m["a"] y m.get("a") está en cómo modelan la
falta: m["a"] corta con error si no hay clave, m.get("a") te
devuelve un Result y vos decidís qué hacer. Si querés evitar el
corte, usá get (y matcheá el Result, cap. 14).
Ver examples/guide/13j-extras-str-map.fitz
para ejemplos de filter/map_values sobre Map.
Métodos de Str¶
| Método | Qué hace |
|---|---|
len() |
Cantidad de caracteres (no bytes). |
upper() |
Devuelve una copia en mayúsculas. |
lower() |
Devuelve una copia en minúsculas. |
Las strings son inmutables: upper/lower devuelven una nueva,
sin tocar la original.
Encadenar métodos¶
Como cada método devuelve un valor, podés enganchar el próximo sobre el resultado. Esto es donde el estilo "objeto primero" se empieza a sentir natural:
let pares_al_cuadrado = [1, 2, 3, 4, 5]
.filter(fn(n) => n == 2 or n == 4)
.map(fn(n) => n * n)
print(pares_al_cuadrado) // [4, 16]
El parser tolera el salto de línea antes de cada ., así que un
chain largo se puede partir en varias líneas — la forma idiomática
cuando el callback de cada paso ocupa lugar:
Es exactamente equivalente a usuarios.filter(...).map(...) en
una sola línea — el AST resultante es idéntico.
Mutación de campos¶
Hasta ahora user.name era solo lectura. En este capítulo se
desbloquea la escritura: user.name = "Otro" reemplaza el valor
del campo en la instancia.
type User { id: Int, name: Str }
let u = User { id: 1, name: "Fitz" }
print(u) // User { id: 1, name: "Fitz" }
u.name = "Roy"
print(u) // User { id: 1, name: "Roy" }
El compilador (estático) eventualmente va a permitir marcar campos
como let/inmutables y forzar el chequeo. Por ahora cualquier
campo es escribible.
Si intentás asignar a un campo que no existe, error claro:
Alias y referencias compartidas¶
Acá entra una decisión de diseño que te va a parecer familiar si venís de Python o JavaScript: las listas, mapas e instancias se pasan por referencia compartida. Eso quiere decir que cuando dos variables apuntan a la misma lista, mutar por una se ve por la otra.
let a = [1, 2]
let b = a // `b` mira la misma lista que `a`.
a.push(3)
print(b) // [1, 2, 3] ← se ve la mutación.
Lo mismo pasa con instancias:
let original = User { id: 1, name: "Fitz" }
let alias = original
alias.name = "Otro"
print(original.name) // Otro
Esto es el mismo modelo que objetos en Python/JS: las primitivas
(Int, Float, Bool, Str, Null) se copian por valor; las
colecciones e instancias se aliasean. Si querés una copia
genuina, hoy hay que reconstruir a mano (xs.map(fn(x) => x) para
listas, por ejemplo). El día que necesitemos un clone() formal lo
sumamos.
Funciones anónimas como callback¶
Los métodos map/filter/find reciben una función. Podés pasar
una fn con nombre, pero lo típico es definir la callback al vuelo
con fn(x) => ... (cap. 11):
let usuarios = [
User { id: 1, name: "Fitz" },
User { id: 2, name: "Roy" },
]
// `find` en una lista de instancias.
let resultado = usuarios.find(fn(u) => u.id == 2)
print(resultado) // Ok(User { id: 2, name: "Roy" })
Como una anónima es un closure, también ve las variables del scope donde fue definida:
Métodos custom sobre type¶
Podés declarar métodos adentro del bloque type. Sintaxis:
fn nombre(params) -> Tipo { body }, separado de los fields por
newline o coma:
type Counter {
count: Int = 0
step: Int = 1
fn current() -> Int {
return count
}
fn next_value() -> Int {
return count + step
}
fn label(prefix: Str) -> Str {
return "{prefix}: {count}"
}
}
let c = Counter { count: 10, step: 5 }
print(c.current()) // 10
print(c.next_value()) // 15
print(c.label("c")) // c: 10
Decisión clave (opción A): los fields del type son variables
locales en el body del método (sin prefijo self.). Es la
convención de Python/Ruby/Crystal — menos boilerplate que self.x
de Rust, y consistente con cómo Fitz expone fields adentro de
struct literals (los defaults pueden referenciar fields previos).
Caveat de shadowing: si un parámetro tiene el mismo nombre que un field, el parámetro gana. Workaround: nombrá distinto el local. Igual que en Rust con bindings:
Method chaining funciona naturalmente cuando un método devuelve otra instancia:
type Point {
x: Int
y: Int
fn doubled_p() -> Point {
return Point { x: x * 2, y: y * 2 }
}
fn show() -> Str {
return "({x}, {y})"
}
}
let p = Point { x: 3, y: 4 }
print(p.doubled_p().show()) // (6, 8)
async fn adentro de type funciona en fitz run y
fitz build (cerrado post-R.3, 2026-05-17). El receiver se
pasa por valor (clone) al método async para que el Future no
holdee el lock del Mutex; los Arc
type Task {
id: Int
async fn label(prefix: Str) -> Str {
sleep(10).await
return "{prefix}-{id}"
}
}
let t = Task { id: 7 }
let l = t.label("step").await // step-7
Métodos estáticos¶
Desde St podés declarar métodos sin receiver con static fn adentro
del type body. Se invocan como Type.method(args) y son útiles
para constructores y factories estilo Rust User::new(...) o
Python classmethods:
type Counter {
value: Int = 0
// Constructor por defaults — `Counter.zero()` es más claro
// que `Counter { value: 0 }` cuando el caller lee.
static fn zero() -> Counter {
return Counter { value: 0 }
}
// Factory parametrizado.
static fn of(n: Int) -> Counter {
return Counter { value: n }
}
// Métodos de instancia siguen funcionando igual.
fn incremented() -> Counter {
return Counter { value: value + 1 }
}
}
let c0 = Counter.zero() // Counter { value: 0 }
let c5 = Counter.of(5) // Counter { value: 5 }
let c6 = c5.incremented() // Counter { value: 6 }
// Pipelines: factory estático + instance methods encadenados.
let p = Counter.of(1).incremented().incremented()
Diferencias con métodos de instancia:
- NO reciben los fields del type como locales: el body solo ve
sus params + globals. Si necesitás los defaults del type,
inicializás vía struct literal (return Counter {} o
return Counter { value: n }).
- Se invocan sobre el tipo, no sobre una instancia. Llamar a
un static fn con una instancia (c.zero()) o un fn regular
con el tipo (Counter.incremented()) da error con mensaje
sugiriendo la forma correcta.
- Pueden ser async: static async fn fetch_default().
Implementación: el codegen emite el static como pub fn adentro
del impl <Type>Data { ... } (Rust associated function), y el call
site Counter.of(5) se traduce a CounterData::of(5i64).
Ver examples/guide/13g-static-methods.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Campos privados¶
Convención estilo Python pero validada por el checker estático: los
campos cuyo nombre arranca con _ son privados — solo accesibles
desde adentro de los métodos del propio type. Desde afuera son
error de tipo.
type Account {
name: Str = "anon"
_balance: Int = 0 // ← privado
static fn new(name: Str) -> Account {
return Account { name: name, _balance: 0 }
}
fn deposit(n: Int) -> Account {
return Account { name: name, _balance: _balance + n }
}
fn balance() -> Int { return _balance }
}
let a = Account.new("Ada").deposit(100)
print(a.balance()) // 100 — vía getter público
print(a._balance) // ERROR: campo `_balance` es privado
Reglas del checker:
| Operación | Desde afuera | Desde método del mismo type |
|---|---|---|
instance._field (acceso) |
error | ok |
Type { _field: v } (struct lit) |
error | ok (constructor) |
instance._field = v (asignación) |
error | ok |
other._field (otra instancia del mismo type) |
error | ok |
El patrón canónico para inicializar un tipo con campos privados es
un constructor estático (Type.new(...)) — la combinación con
métodos estáticos es natural.
El LSP autocomplete también respeta la regla: después de instance.
NO sugiere campos _field. Adentro de un método del propio type
siguen apareciendo (como locales).
Métodos privados¶
La misma convención aplica a métodos: _method() solo se puede
invocar desde adentro de métodos del propio type. El checker
rechaza instance._method() desde afuera con mensaje claro.
type Counter {
n: Int = 0
fn _bumped_by(by: Int) -> Counter { // ← privado
return Counter { n: n + by }
}
static fn bump_twice(c: Counter) -> Counter {
return c._bumped_by(2) // ok: adentro del type
}
}
let c = Counter.bump_twice(Counter {})
print(c._bumped_by(1)) // ERROR: privado
Caveat de R.3 (opción A): los métodos de instancia NO pueden
llamarse entre sí adentro del mismo body — no hay self ni this.
El patrón canónico para componer métodos privados con públicos es
usar static fn que recibe la instancia como param y delega.
LSP autocomplete también filtra _method en instance..
Ver examples/guide/13i-campos-privados.fitz
para el ejemplo completo (incluye sección Vm) — validado bit-a-bit
fitz run ↔ fitz build.
Limitaciones del MVP (R.3 + St + Vp + Vm):
- Sin operator overloading (fn +(self, other)).
- Métodos de instancia no pueden llamar a otros métodos del mismo
type sin un receiver explícito (no hay self). Workaround:
extraer la lógica común a un static fn que recibe la instancia.
Ver examples/guide/13b-metodos-custom.fitz para el ejemplo completo de métodos de instancia (incluye async).
Métodos chicos de Str y List¶
Resumen de los métodos disponibles sobre Str y List:
Sobre Str (S.1 + S.2 + Mb + Ex):
| Método | Args | Retorna | Notas |
|---|---|---|---|
.contains(s) |
Str |
Bool |
empty string siempre matchea |
.starts_with(s) |
Str |
Bool |
case-sensitive |
.ends_with(s) |
Str |
Bool |
case-sensitive |
.split(sep) |
Str |
List<Str> |
empty separator → chars individuales |
.trim() |
— | Str |
whitespace ambos lados |
.trim_start() |
— | Str |
whitespace solo al inicio (Mb) |
.trim_end() |
— | Str |
whitespace solo al final (Mb) |
.replace(o, n) |
Str, Str |
Str |
TODAS las ocurrencias |
.repeat(n) |
Int |
Str |
n < 0 es error |
.find(s) |
Str |
Result<Int> |
índice (en chars) de la 1ra ocurrencia (Ex) |
.index_of(s) |
Str |
Result<Int> |
alias de find (estilo JS/TS) (Ex) |
.last_index_of(s) |
Str |
Result<Int> |
índice de la ÚLTIMA ocurrencia (Ex) |
.pad_start(w, c) |
Int, Str |
Str |
padding a la izquierda; c debe ser 1 char (Mb2) |
.pad_end(w, c) |
Int, Str |
Str |
padding a la derecha; c debe ser 1 char (Mb2) |
.chars() |
— | List<Str> |
cada char como Str de 1 caracter (Mb3) |
.split_at(i) |
Int |
(Str, Str) |
divide en char idx; idx >= len → segundo vacío (Mb4) |
.lines() |
— | List<Str> |
separa por \n; ignora \n final (Mb5) |
.is_empty() |
— | Bool |
atajo de len() == 0 (Mb5) |
.repeat_with(n, sep) |
Int, Str |
Str |
repite intercalando sep; n < 0 error (Mb7) |
.left(n) |
Int |
Str |
primeros n chars; n <= 0 → vacío (Mb8) |
.right(n) |
Int |
Str |
últimos n chars; clamp safe (Mb8) |
.center(w, ch) |
Int, Str |
Str |
centra con padding ch a ambos lados (Mb8) |
Sobre List<T> (S.3 + Mb + Lx):
| Método | Args | Retorna | Notas |
|---|---|---|---|
.sort() |
— | Null |
IN-PLACE, T ∈ |
.sort_by(cmp) |
fn(T, T) -> Int |
Null |
IN-PLACE, callback estilo Rust/JS cmp (Mb) |
.reverse() |
— | Null |
IN-PLACE, cualquier T |
.contains(v) |
T |
Bool |
igualdad estructural |
.flatten() |
— | List<U> |
requiere List<List<U>>, aplana un nivel (Mb) |
.any(pred) |
fn(T) -> Bool |
Bool |
¿existe alguno? Vacía → false (Lx) |
.all(pred) |
fn(T) -> Bool |
Bool |
¿todos? Vacía → true (vacuously) (Lx) |
.count(pred) |
fn(T) -> Bool |
Int |
cuántos cumplen (Lx) |
.find_index(pred) |
fn(T) -> Bool |
Result<Int> |
índice del primero o Err (Lx) |
.flat_map(fn) |
fn(T) -> List<U> |
List<U> |
map + flatten en un paso (Ex2) |
.first() |
— | Result<T> |
primer elemento o Err("lista vacía") (Ex2) |
.last() |
— | Result<T> |
último elemento o Err (Ex2) |
.min() |
— | Result<T> |
mínimo numérico; T ∈ {Int, Float}; vacía → Err (Mb2) |
.max() |
— | Result<T> |
máximo numérico; T ∈ {Int, Float}; vacía → Err (Mb2) |
.sum() |
— | T |
suma numérica; T ∈ {Int, Float}; vacía → 0 (Mb2) |
.product() |
— | T |
producto numérico; T ∈ {Int, Float}; vacía → 1 (Mb3) |
.reduce(init, fn) |
Acc, fn(Acc, T) -> Acc |
Acc |
fold canónico; Acc puede ser distinto de T (Mb3) |
.to_map() |
— | Map<K, V> |
requiere List<(K, V)>; last-write-wins (Mb3) |
.unique() |
— | List<T> |
dedup preservando orden de 1ra aparición (Mb4) |
.partition(pred) |
fn(T) -> Bool |
(List<T>, List<T>) |
divide en truthy/falsy preservando orden (Mb4) |
.group_by(fn) |
fn(T) -> K |
Map<K, List<T>> |
agrupa por key derivada del callback (Mb5) |
.zip_with(ys, fn) |
List<U>, fn(T, U) -> V |
List<V> |
zip + map en un paso; trunca al más corto (Mb5) |
.max_by(fn) |
fn(T) -> Int |
Result<T> |
item con max ranking; vacía → Err (Mb5) |
.min_by(fn) |
fn(T) -> Int |
Result<T> |
item con min ranking; vacía → Err (Mb5) |
.scan(init, fn) |
Acc, fn(Acc, T) -> Acc |
List<Acc> |
fold con outputs intermedios (cumulative) (Mb6) |
.windows(n) |
Int |
List<List<T>> |
sliding windows de tamaño n; len < n → vacía (Mb6) |
.take(n) |
Int |
List<T> |
primeros n elementos; clamp safe (Mb7) |
.drop(n) |
Int |
List<T> |
saltea primeros n; clamp safe (Mb7) |
.init() |
— | List<T> |
todos menos el último; vacía → vacía (Mb7) |
.tail() |
— | List<T> |
todos menos el primero; vacía → vacía (Mb7) |
.intersperse(sep) |
T |
List<T> |
inserta sep entre elementos (Mb7) |
.cycle(n) |
Int |
List<T> |
repite la lista n veces; n <= 0 → vacía (Mb7) |
.starts_with(p) |
List<T> |
Bool |
la lista empieza con el prefix p (Mb8) |
.ends_with(s) |
List<T> |
Bool |
la lista termina con el suffix s (Mb8) |
.insert_at(i, v) |
Int, T |
List<T> |
functional: nueva lista con v en idx i (Mb8) |
.remove_at(i) |
Int |
List<T> |
functional: nueva lista sin idx i (Mb8) |
.zip_to_map(vs) |
List<V> |
Map<K, V> |
combina keys (self) con values; trunca al corto (Mb8) |
Ver examples/guide/13c-metodos-extras.fitz para los métodos S, examples/guide/13e-mini-bundle-metodos.fitz para los de Mb, y examples/guide/13h-predicados-list.fitz para los predicados de Lx.
Iteradores: enumerate / zip / chain¶
Tres métodos canónicos para componer listas sin loops manuales, inspirados en Python/Rust. Todos devuelven una lista nueva (no mutan el receptor).
| Método | Args | Retorna | Notas |
|---|---|---|---|
.enumerate() |
— | List<(Int, T)> |
Pares (índice, elemento). |
.zip(ys) |
List<U> |
List<(T, U)> |
Empareja dos listas; trunca al más corto. |
.chain(ys) |
List<T> |
List<T> |
Concatena dos listas del mismo tipo. |
El caso canónico de enumerate combina con el tuple destructuring
del for:
let nombres: List<Str> = ["ada", "bea", "cam"]
for (i, n) in nombres.enumerate() {
print("{i}: {n}")
}
// 0: ada / 1: bea / 2: cam
zip permite recorrer dos listas en paralelo. Si los tamaños
difieren, trunca al menor (paralelo a Python):
let valores: List<Int> = [10, 20, 30]
let pesos: List<Int> = [1, 2]
let pares: List<(Int, Int)> = valores.zip(pesos)
print(pares.len()) // 2 (no 3 — `pesos` tiene 2 items)
chain concatena (sin mutar):
let primeras: List<Int> = [1, 2, 3]
let segundas: List<Int> = [4, 5]
let todo: List<Int> = primeras.chain(segundas)
print(todo.len()) // 5
Ver examples/guide/13d-iteradores.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Iteradores sobre Range¶
Range también expone enumerate/zip/chain/len desde Ir.
Antes había que materializar con un list comprehension ([n for n
in 0..10].enumerate()) o caer a un loop manual con counter.
// enumerate sobre Range — el caso canónico
for (i, n) in (0..5).enumerate() {
print("{i}: cuadrado de {n} = {n * n}")
}
// zip Range + List — el Range "infinito" 1..100 se trunca por la lista
let usuarios: List<Str> = ["ada", "bea", "cam"]
for (id, name) in (1..100).zip(usuarios) {
print("user#{id}: {name}")
}
// chain Range + List — concatena
let extras: List<Int> = [100, 200]
print((0..3).chain(extras)) // [0, 1, 2, 100, 200]
// len sin materializar
print((0..10).len()) // 10
print((0..=10).len()) // 11 (inclusive)
| Método | Args | Retorna | Notas |
|---|---|---|---|
.enumerate() |
— | List<(Int, Int)> |
Pares (índice, valor). |
.zip(ys) |
List<U> |
List<(Int, U)> |
Trunca al más corto. |
.chain(ys) |
List<Int> |
List<Int> |
Concatena con otra List<Int>. |
.len() |
— | Int |
Cantidad de elementos. |
Implementación: el runtime materializa el Range a List<Int> y
delega; el codegen inline-a (start..end).collect::<Vec<i64>>()
adentro del wrap Arc<Mutex<>>.
Caveats:
- Range.chain(Range) directo NO funciona: chain espera
List<Int>. Materializá con un list comprehension:
[n for n in 0..3].chain([n for n in 5..8]).
- Range no expone map/filter/find/sort/etc. — son
métodos que mutan/transforman; usá la lista materializada
con el comprehension primero.
Ver examples/guide/13f-range-iteradores.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Reducciones + padding + keys ordenadas + Range con step¶
Bundle de polish ergonómico que cubre cuatro frentes chicos:
List.min() / List.max() / List.sum() — reducciones
numéricas sobre List<Int> o List<Float>. min y max devuelven
Result<T> porque la lista puede estar vacía; sum devuelve T
(con 0/0.0 como sentinel para vacío). Tipos no numéricos
disparan error.
let nums: List<Int> = [3, 1, 4, 1, 5, 9, 2, 6]
match nums.min() {
Ok(v) => print("min: {v}"), // min: 1
Err(_) => print("vacía")
}
print("sum: {nums.sum()}") // sum: 31
let temps: List<Float> = [22.5, 21.0, 23.8]
print("máxima: {temps.max()}") // máxima: Ok(23.8)
Str.pad_start(width, ch) / Str.pad_end(width, ch) — padding
estilo Python str.rjust(width, ch) / str.ljust(width, ch). ch
debe ser exactamente 1 char (validado en runtime). Si len(s) >=
width, devuelve s sin cambios.
print("42".pad_start(5, "0")) // 00042
print("hi".pad_end(5, ".")) // hi...
print("hola, mundo".pad_start(5, "*")) // hola, mundo
Map.keys_sorted() — devuelve List<K> con las keys
ordenadas. Solo para K en {Int, Float, Str, Bool} (mismas reglas
que list.sort). Map vacío → lista vacía. Maps preservan insertion
order por diseño; keys_sorted es el escape cuando querés orden
canónico para iterar.
let scores: Map<Str, Int> = {"banana": 3, "apple": 1, "cherry": 5}
print(scores.keys_sorted()) // ["apple", "banana", "cherry"]
for k in scores.keys_sorted() {
match scores.get(k) {
Ok(v) => print("{k.pad_end(8, \".\")}: {v}"),
Err(_) => {}
}
}
// apple...: 1 / banana..: 3 / cherry..: 5
Range.step_by(n) — materializa el rango con step n (debe
ser Int > 0). Encadena natural con los otros métodos de List —
útil para iterar saltando elementos.
let evens: List<Int> = (0..10).step_by(2)
print(evens) // [0, 2, 4, 6, 8]
let by_three: List<Int> = (0..=20).step_by(3)
print(by_three) // [0, 3, 6, 9, 12, 15, 18]
let suma_pares: Int = (0..100).step_by(2).sum()
print(suma_pares) // 2450
Ver examples/guide/13m-min-max-sum-pad-keys-step.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Fold + product + chars + entries + to_map¶
Bundle de métodos funcionales que completan la API canónica "functional collections":
List.reduce(init, fn(acc, x) -> Acc) — fold canónico.
Equivalente a Array.prototype.reduce(fn, init) de JS o
functools.reduce(fn, xs, init) de Python. El Acc puede ser de
un tipo distinto al de los elementos (caso típico: reducir una
List<Int> a un Str construyendo una representación).
let xs: List<Int> = [1, 2, 3, 4, 5]
let total: Int = xs.reduce(0, fn(acc: Int, x: Int) => acc + x)
print(total) // 15
// Acc de otro tipo:
let csv: Str = xs.reduce("", fn(acc: Str, x: Int) => "{acc}{x},")
print(csv) // 1,2,3,4,5,
// Lista vacía → devuelve el init.
let empty: List<Int> = []
print(empty.reduce(42, fn(acc: Int, x: Int) => acc + x)) // 42
List.product() — análogo a sum(). Solo válido sobre
List<Int> o List<Float> homogéneos. Vacío → 1 (sentinel,
paralelo a Python math.prod([])).
Str.chars() — devuelve List<Str> con cada caracter como
Str de 1 char. Útil para componer pipelines sobre strings (chars
+ filter + count + ...). Cuenta caracteres Unicode correctamente,
no bytes.
let cs: List<Str> = "fitz".chars()
print(cs) // ["f", "i", "t", "z"]
// Pipeline canónico — contar vocales.
let vocales: Int = "hello".chars().count(fn(c: Str) => c == "e" or c == "o")
print(vocales) // 2
Map.entries() ↔ List<(K, V)>.to_map() — conversión
bidireccional Map ↔ lista de pares. entries() preserva insertion
order (paralelo a Python dict.items()); to_map() aplica
last-write-wins en duplicados (paralelo a Python dict(items)).
let scores: Map<Str, Int> = {"ada": 80, "bob": 92}
// entries: Map → List<(K, V)>
let pairs: List<(Str, Int)> = scores.entries()
print(pairs) // [("ada", 80), ("bob", 92)]
// to_map: List<(K, V)> → Map
let back: Map<Str, Int> = pairs.to_map()
print(back["ada"]) // 80
// Combinar entries() con List.reduce:
let total: Int = scores.entries().reduce(0,
fn(acc: Int, p: (Str, Int)) => acc + p.1)
print(total) // 172
Ver examples/guide/13n-reduce-product-chars-entries-to-map.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Higher-order por nombre + constantes globales¶
Bundle de polish del codegen — cierra las dos limitaciones más visibles del compilador heredadas:
Higher-order callbacks por nombre — antes el codegen exigía
callbacks inline fn(x) => ... para los métodos de colección.
Ahora podés pasar una fn nombrada directamente:
fn double(n: Int) -> Int { return n * 2 }
fn is_even(n: Int) -> Bool { return n % 2 == 0 }
fn sumar(acc: Int, x: Int) -> Int { return acc + x }
let xs: List<Int> = [1, 2, 3, 4, 5]
print(xs.map(double)) // [2, 4, 6, 8, 10]
print(xs.filter(is_even)) // [2, 4]
print(xs.reduce(0, sumar)) // 15
Cubre map/filter/find/any/all/count/find_index/
flat_map/reduce/sort_by y los callbacks binarios de Map
(filter, map_values, update). El checker valida aridad +
tipos de params + ret type contra la signature de la fn (igual
que con callbacks inline).
Constantes globales — antes, un let X = 100 top-level NO
era accesible desde fns top-level en fitz build ("variable
desconocida en codegen"). Había que pasarla como param a cada
fn. Ahora, si la RHS es const-eval (literal Int/Float/Bool
o Str literal, o BinOp/UnaryOp puros sobre operands const-eval)
Y la var es referenciada por al menos una fn top-level, el
codegen la "hoistea" automáticamente a const X: T = ...; o
static X: &str = ...; Rust.
let MAX = 100
let GREETING: Str = "hola"
let LIMIT = 10 * 2 + 5 // const-eval con BinOp
fn cap(n: Int) -> Int {
if (n > MAX) { return MAX }
return n
}
fn greet(name: Str) -> Str {
return "{GREETING}, {name}"
}
fn check(n: Int) -> Bool {
return n < LIMIT
}
print(cap(50)) // 50
print(cap(200)) // 100
print(greet("Ada")) // hola, Ada
print(check(20)) // true
Reglas del hoist:
- Solo aplica a let X = <const-eval> o let X: Str = "literal".
RHS con calls a fns, struct lits o expresiones runtime NO se
hoistan (siguen como locales de main(), el codegen rechaza
si las referencia una fn).
- Si X se reasigna (let X = 10; X = 20), el hoist se cancela
(Rust const es inmutable). Workaround: pasarla como param a
cada fn que la necesite, o Mutex (futuro).
- Solo aplica a modo CLI. En modo HTTP, el mecanismo de state
compartido (thread_local) ya cubre el caso.
Ver examples/guide/13o-higher-order-y-consts-globales.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Dedup + partition + invert + split_at + multi-for / Map comprehensions¶
Bundle que combina cuatro métodos chicos (Mb4) con extensiones de comprehensions (Cmp+) — son features chicos pero suman valor visible para el día a día.
List.unique() — dedup preservando orden de 1ra aparición.
Usa igualdad estructural; cualquier T. Paralelo a Python
list(dict.fromkeys(xs)).
List.partition(pred) — divide en (truthy, falsy) preservando
orden. Callback fn(T) -> Bool.
let nums: List<Int> = [1, 2, 3, 4, 5, 6]
let split: (List<Int>, List<Int>) = nums.partition(fn(n: Int) => n % 2 == 0)
print(split.0) // [2, 4, 6] (pares)
print(split.1) // [1, 3, 5] (impares)
Map.invert() — intercambia keys ↔ values. Devuelve
Map<V, K>. Last-write-wins en values duplicados (paralelo a
to_map).
let id_to_name: Map<Int, Str> = {1: "Ada", 2: "Bob"}
let name_to_id: Map<Str, Int> = id_to_name.invert()
print(name_to_id["Ada"]) // 1
Str.split_at(idx) — divide el string en char idx (no bytes)
y devuelve (Str, Str). idx >= len → segundo elemento vacío;
idx < 0 → error de runtime.
Comprehensions con múltiples for clauses — cartesian product
estilo Python. Las variables del segundo for ven los bindings del
primero (útil para listas dependientes):
let xs: List<Int> = [1, 2, 3]
let ys: List<Int> = [10, 20]
let combos: List<Int> = [x + y for x in xs for y in ys]
print(combos) // [11, 21, 12, 22, 13, 23]
// Con filter al final (aplica en el loop más interno):
let mostly: List<Int> = [x * y for x in xs for y in ys if x % 2 == 1]
print(mostly) // [10, 20, 30, 60]
// El segundo iter puede depender del primero (triangular).
let tri: List<(Int, Int)> = [(i, j) for i in 1..=3 for j in 1..=i]
print(tri.len()) // 6 — pares (1,1), (2,1), (2,2), (3,1), (3,2), (3,3)
Map comprehensions — sintaxis {key_expr: value_expr for var in iter}.
Soporta múltiples for clauses y filter opcional (igual que list
comprehensions). En keys duplicadas, last-write-wins (paralelo a
Python dict comprehension).
// Tabla de cuadrados 1..=5:
let squares: Map<Int, Int> = {n: n * n for n in 1..=5}
print(squares[3]) // 9
print(squares[5]) // 25
// Con filter:
let big: Map<Int, Int> = {n: n * n for n in 1..=10 if n > 5}
print(big[10]) // 100
// Construir map desde una lista, transformando cada elemento:
let nombres: List<Str> = ["Ada", "Bob", "Cam"]
let lens: Map<Str, Int> = {name: name.len() for name in nombres}
print(lens["Ada"]) // 3
Ver examples/guide/13p-mb4-y-comprehensions-extendidas.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
group_by + zip_with + max_by/min_by + lines + async closures¶
Bundle siguiente a Mb4 — combina cuatro métodos analíticos sobre
colecciones, dos sobre Str, y un feature nuevo: async closures
inline.
List.group_by(fn(T) -> K) — agrupa por una key derivada del
callback. Output: Map<K, List<T>>. Preserva orden — el primer
item con key K define posición en el map, items posteriores se
acumulan en su List<T>.
type User { name: Str = "", role: Str = "guest" }
let users: List<User> = [...]
let by_role: Map<Str, List<User>> = users.group_by(fn(u: User) => u.role)
print(by_role["admin"].len())
List.zip_with(ys, fn(T, U) -> V) — combina zip + map en un
paso (devuelve directamente la transformación, sin pares crudos
intermedios). Trunca al más corto (paralelo a Python zip).
let xs: List<Int> = [1, 2, 3]
let ys: List<Int> = [10, 20, 30]
let r: List<Int> = xs.zip_with(ys, fn(a: Int, b: Int) => a + b)
print(r) // [11, 22, 33]
List.max_by(fn(T) -> Int) / List.min_by(fn(T) -> Int) — útil
para tipos no numéricos (Instance, Str, etc.) donde max/min
directos no aplican. El callback extrae un Int ranking; devuelve
el item con max/min ranking. Vacía → Err.
type Item { score: Int = 0, name: Str = "" }
let items: List<Item> = [...]
match items.max_by(fn(it: Item) => it.score) {
Ok(best) => print("mejor: {best.name}"),
Err(_) => print("vacío")
}
Str.lines() y Str.is_empty() — utilidades de strings:
lines separa por \n (sin agregar línea vacía si el string
termina con \n, paralelo a str::lines Rust); is_empty es
atajo de len() == 0 con intención clara.
let txt = "Hola\nFitz\n\nFin"
let renglones: List<Str> = txt.lines()
print(renglones.len()) // 4 (incluye la vacía intermedia)
let validas: List<Str> = renglones.filter(fn(l: Str) => not l.is_empty())
print(validas.len()) // 3
Async closures inline — async fn(...) => expr o async fn(...) { ... }
como expresión: el closure resultante es invocable y devuelve un
Future<T> que el caller debe .awaitar (igual que con fns async
top-level). Habilita patrones funcionales async.
async fn run() -> Int {
// Async closure asignado a una var
let delayed = async fn(n: Int) -> Int {
sleep(1).await
return n * 2
}
return delayed(21).await // 42
}
run().await
Caveat de fitz build: las async closures inline funcionan
en fitz run pero NO en fitz build (boxing de Future<T> con
Pin<Box<dyn Future>> requiere infraestructura que no está
todavía). El error de codegen sugiere el workaround: declarar la
fn async top-level y referenciarla por nombre.
Ver examples/guide/13q-mb5-y-async-closures.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build
para los métodos Mb5; las async closures sólo en fitz run).
scan + windows + merge_with + async closures en build¶
Bundle combinado de polish: 3 métodos analíticos adicionales (Mb6),
cierre del caveat de Async-cl build (async closures inline ahora
compilan en fitz build), y dos refinamientos del stack HTTP.
List.scan(init, fn(acc, x) -> Acc) -> List<Acc> — fold con
outputs intermedios. Devuelve una lista con cada estado del
acumulador después de procesar cada elemento. Útil para sumas
parciales, máximos acumulados, etc. Paralelo a Iterator::scan
de Rust (sin la sutileza del Option).
let xs: List<Int> = [1, 2, 3, 4]
let prefix_sums: List<Int> = xs.scan(0, fn(acc: Int, x: Int) => acc + x)
print(prefix_sums) // [1, 3, 6, 10]
List.windows(n) -> List<List<T>> — sliding windows de tamaño
n. Cada ventana es una List<T> con n elementos consecutivos.
Si len(xs) < n, devuelve lista vacía. n <= 0 → error claro.
Paralelo a slice::windows de Rust.
Map.merge_with(other, fn(V, V) -> V) -> Map<K, V> — merge
generalizado: el callback decide qué value queda cuando hay
conflict de keys (caso típico: fn(a, b) => a + b para sumar
valores). Generaliza merge (que es last-write-wins).
let counts_a: Map<Str, Int> = {"x": 5, "y": 3}
let counts_b: Map<Str, Int> = {"y": 7, "z": 2}
let total: Map<Str, Int> = counts_a.merge_with(counts_b,
fn(a: Int, b: Int) => a + b)
print(total["x"]) // 5
print(total["y"]) // 10 — sumados
print(total["z"]) // 2
Async closures inline en fitz build — el caveat de Async-cl
(la mini-tanda anterior) está cerrado: async fn(...) como
expresión ahora compila a binario nativo. El codegen emite
move |args| -> Pin<Box<dyn Future<Output=T> + Send>> { Box::pin(
async move { ... }) }. Paridad bit-a-bit fitz run ↔ fitz build.
async fn run() -> Int {
let f = async fn(n: Int) -> Int {
sleep(1).await
return n * 2
}
return f(21).await
}
print(run().await) // 42
HTTP — Status codes específicos por kind de Err — cuando un
handler retorna Result<T, ApiErr> y la ApiErr es un type con
field status: Int, el runtime HTTP usa ese status code en lugar
del 500 histórico. El body de la response es la Instance
serializada íntegra (no envuelta en {"error": ...}):
type ApiErr {
status: Int = 500
message: Str = ""
}
@get("/users/{id}")
fn get_user(id: Int) -> Result<Str, ApiErr> {
if (id == 0) { return Err(ApiErr { status: 404, message: "no encontrado" }) }
if (id < 0) { return Err(ApiErr { status: 400, message: "id inválido" }) }
return Ok("Ada")
}
Sin field status o con status fuera de rango (100..1000), fallback
al 500 histórico con {"error": <inner>}.
HTTP — CORS request-aware con echo del Origin sin filtro — el
config allow_origin: "echo" (Str literal especial) hace echo del
Origin recibido sin filtro (acepta cualquier Origin). Útil para dev
local donde no se conoce la lista de frontends a priori. Si la
request no manda Origin, NO se emite el header (paralelo a Set
sin match).
Ver examples/guide/13r-mb6-y-async-build.fitz
para el ejemplo completo de Mb6 + async closures (validado bit-a-bit
fitz run ↔ fitz build).
take + drop + init + tail + intersperse + cycle + repeat_with + with + format specs en build¶
Bundle final de polish: 7 métodos chicos sobre colecciones y Str +
completar los format specs que faltaban en fitz build.
List.take(n) / List.drop(n) — primeros/restantes elementos.
Paralelo a Rust Iterator::take/skip. Clamp safe: n fuera de
rango no es error (n <= 0 → vacía/full, n >= len → full/vacía).
List.init() / List.tail() — todos menos último/primero
respectivamente. Paralelo a Haskell. Sobre lista vacía → lista
vacía (sin error).
List.intersperse(sep) — inserta sep entre cada par de
elementos consecutivos.
let palabras: List<Str> = ["hola", "fitz", "rust"]
print(palabras.intersperse(" | ")) // ["hola", " | ", "fitz", " | ", "rust"]
List.cycle(n) — repite la lista n veces. n <= 0 → vacía
(política friendly, no error).
Str.repeat_with(n, sep) — variante de repeat(n) que
intercala sep entre repeticiones. Paralelo a Python
sep.join([s] * n).
Map.with(k, v) — functional update. Devuelve un Map nuevo
con k → v; el receiver queda intacto. Si k ya existe,
sobreescribe (last-write-wins). Encadenable para construir Maps
paso a paso sin mutar.
let base: Map<Str, Int> = {"a": 1, "b": 2}
let extendido: Map<Str, Int> = base.with("c", 3)
print(extendido.len()) // 3
print(base.len()) // 2 — intacto
let chain: Map<Str, Int> = base.with("x", 10).with("y", 20).with("z", 30)
print(chain.len()) // 5
Format specs en fitz build (cierre de deuda Fm) — los format
specs ,/_ grouping, % percent y c char ahora compilan en
fitz build (antes solo andaban en fitz run). El codegen emite
helpers __fitz_fmt_grouping/__fitz_fmt_percent/__fitz_fmt_char
en el preludio cuando el programa los usa.
// Grouping con `,` o `_`:
let big = 1234567
print("{big:,d}") // 1,234,567
print("{big:_d}") // 1_234_567
// Percent (multiplica x100, agrega %):
let ratio = 0.857
print("{ratio:.2%}") // 85.70%
// Char codepoint (Int → caracter Unicode):
let cp = 65
print("{cp:c}") // A
// Combinación con width/align:
print("|{big:>15,d}|") // | 1,234,567|
Deuda residual menor (sub-paso futuro): el spec g/G
(general) sigue solo en fitz run — requiere lógica de
"decide entre fixed y exponente según magnitud" que es más
involucrada. Sin presión real.
Ver examples/guide/13s-mb7-y-fmt-build.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Sublistas + functional updates + ops de bits + g/G format¶
Bundle final de polish del lenguaje base: 8 métodos chicos sobre
colecciones y Str (Mb8) + 5 builtins globales sobre Int (Bits-extras)
+ completar el último format spec faltante (g/G general).
List.starts_with(prefix) / List.ends_with(suffix) — devuelve
Bool. Igualdad estructural. Prefix/suffix vacío → true. Útil para
parsing simple de tokens, paths, etc.
let xs: List<Int> = [1, 2, 3, 4, 5]
print(xs.starts_with([1, 2])) // true
print(xs.ends_with([4, 5])) // true
List.insert_at(i, v) / List.remove_at(i) — functional
updates (devuelven lista nueva, receiver intacto). insert_at clamp
seguro: idx >= len inserta al final; idx < 0 → error. remove_at
exige idx en rango.
let base: List<Int> = [10, 20, 40]
print(base.insert_at(2, 30)) // [10, 20, 30, 40]
print(base.remove_at(0)) // [20, 40]
List.zip_to_map(values) — combina la lista de keys (self) con
una lista de values formando un Map<K, V>. Trunca al más corto.
Equivalente a Python dict(zip(ks, vs)).
let ks: List<Str> = ["a", "b", "c"]
let vs: List<Int> = [1, 2, 3]
let m: Map<Str, Int> = ks.zip_to_map(vs)
print(m["a"]) // 1
Str.left(n) / Str.right(n) — primeros/últimos n chars.
Char-based, no byte-based. Clamp safe: n <= 0 → vacío, n >= len
→ string completo.
Str.center(width, ch) — centra el string padeando con ch a
ambos lados hasta width chars. ch debe ser 1 char. Si el padding
es impar, el extra va a la derecha (paralelo a Python).
print("hi".center(10, "-")) // "----hi----"
print("Reporte".center(30, "=")) // "===========Reporte============"
popcount(n) / leading_zeros(n) / trailing_zeros(n) /
rotate_left(n, bits) / rotate_right(n, bits) — builtins
globales sobre Int (64 bits). Útil para máscaras, flags, ops bit-a-bit
de bajo nivel.
Format spec g/G en fitz build (cierra el último item de
deuda residual de Fm/Fmt-build) — decide entre fixed y exponente
según magnitud y precision; quita ceros trailing del decimal
(paralelo a Python).
let x = 1234.5
print("{x:g}") // 1234.5
let chico = 0.00001
print("{chico:g}") // 1.00000e-5
print("{1234567890.0:G}") // 1.23457E9
Ver examples/guide/13t-mb8-bits-y-fmt-g.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Math + métodos sobre Int/Float¶
Bundle numérico + polish final. Tres bloques que destraban code chains comunes en aritmética, validación de strings, y dispatch de método sobre primitivos.
Builtins numéricos globales (abs, min, max, pow, sqrt,
ceil, floor, round, clamp):
| Builtin | Tipo de retorno | Notas |
|---|---|---|
abs(n) |
Int o Float |
Mismo tipo que el arg. |
min(a, b) / max(a, b) |
Int o Float |
Ambos args deben ser del mismo tipo. |
pow(base, exp) |
Float |
Siempre Float, incluso con args Int. |
sqrt(x) |
Float |
Siempre Float. |
ceil(x) / floor(x) / round(x) |
Int |
Siempre Int. Pasa Int de largo. |
clamp(x, lo, hi) |
Int o Float |
Tres args del mismo tipo numérico. |
print(abs(-5)) // 5
print(abs(-3.14)) // 3.14
print(min(3, 5)) // 3
print(pow(2, 10)) // 1024.0
print(sqrt(16)) // 4.0
print(ceil(3.2)) // 4
print(floor(3.8)) // 3
print(round(3.5)) // 4
print(clamp(5, 0, 10)) // 5
print(clamp(-5, 0, 10)) // 0
print(clamp(15, 0, 10)) // 10
Métodos sobre Str:
Str.swap_case() / Str.title() — manipulación de case.
print("Hola Mundo".swap_case()) // "hOLA mUNDO"
print("hola mundo de fitz".title()) // "Hola Mundo De Fitz"
Str.is_alpha() / is_digit() / is_numeric() — predicates de
contenido. Útiles para validación rápida sin regex. Vacío → false
en los tres (vacuous truth invertida, paralelo a Python).
| Método | Acepta |
|---|---|
is_alpha() |
Todos chars son letras ASCII ([a-zA-Z]+). |
is_digit() |
Todos chars son dígitos [0-9]+ (estricto). |
is_numeric() |
El string completo parsea como número (Int o Float, signo opcional). |
print("hola".is_alpha()) // true
print("hola123".is_alpha()) // false
print("12345".is_digit()) // true
print("3.14".is_numeric()) // true (parsea como Float)
print("-42".is_numeric()) // true (signo OK)
print("3.14.5".is_numeric()) // false
List.split_at(i) — parte la lista en dos en idx, devuelve
Tuple de dos lists nuevas. Clamp safe en ambos extremos.
let xs: List<Int> = [1, 2, 3, 4, 5]
let parts = xs.split_at(2)
print(parts) // ([1, 2], [3, 4, 5])
print(xs.split_at(0)) // ([], [1, 2, 3, 4, 5])
print(xs.split_at(99)) // ([1, 2, 3, 4, 5], [])
Map.has_value(v) — chequea si v está presente como value
en algún par del map. Complementa has(k) (chequea por key).
let scores: Map<Str, Int> = {"ada": 92, "bob": 85, "cam": 92}
print(scores.has_value(92)) // true
print(scores.has_value(0)) // false
Métodos sobre primitivos Int y Float:
Cierra deuda: ahora podés hacer n.abs(), x.is_nan(), etc.
sobre Int y Float directamente. Útil para method chaining y
APIs uniformes.
| Receptor | Método | Descripción |
|---|---|---|
Int |
n.abs() |
Equivalente a abs(n). |
Int |
n.to_str() |
Convierte a Str. |
Int |
n.to_str_base(b) |
Convierte a Str en base 2/8/10/16. |
Float |
x.abs() |
Equivalente a abs(x). |
Float |
x.to_str() |
Convierte a Str (mismo formato que print). |
Float |
x.is_nan() |
true si es NaN. |
Float |
x.is_finite() |
true si NO es NaN ni infinito. |
let n: Int = -42
print(n.abs()) // 42
print((42).to_str()) // "42"
print((255).to_str_base(16)) // "ff"
print((10).to_str_base(2)) // "1010"
let x: Float = -3.14
print(x.abs()) // 3.14
print((1.0).is_nan()) // false
print((1.0).is_finite()) // true
Ver examples/guide/13u-math-mb9-y-int-float.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
return/break/continue en match arm¶
Cada arm de match ahora puede contener return/break/continue
(o un bloque de stmts) además de la expresión clásica. Esto cierra
la deuda histórica donde el match era pure-expression y no se
podía cortar la fn desde adentro.
fn classify(n: Int) -> Str {
match n {
0 => return "cero" // early return desde un arm
1..10 => return "chico"
_ => return "grande"
}
return "unreachable"
}
Tres formas de body por arm:
- Expresión simple:
pat => expr(forma clásica). - Control flow directo:
pat => return X,pat => break,pat => continue. - Bloque con varios stmts:
pat => { let x = ...; return x * 2 }.
fn compute(n: Int) -> Int {
return match n {
0 => {
let bonus: Int = 10
return bonus * 2
}
1..100 => 99
_ => -1
}
}
print(compute(0)) // 20
print(compute(50)) // 99
Ver examples/guide/13v-return-en-match.fitz para el ejemplo completo.
Lo que todavía no anda¶
- (la API de métodos sobre
Str/List/Map/Range/Int/Floatcubre el 99% de los casos. Si te falta uno puntual, abrí un issue.)
Features completas: encadenamiento multi-línea, asignación a índice
xs[0] = vym["k"] = v(ver cap 9), métodos custom sobretype(sub-sección de arriba), métodos chicos de Str y List (.contains/.starts_with/.ends_with/.split/.trim/.replace/.repeatsobre Str;.sort/.reverse/.containssobre List), iteradores.enumerate()/.zip()/.chain(), dispatch sobre primitivosInt/Float(n.abs(),x.is_nan(), etc. — ver sub-sección de arriba). Forma idiomática del chain multi-línea:
Ejemplo completo¶
examples/guide/13-metodos.fitz:
type User { id: Int, name: Str }
let usuarios = [
User { id: 1, name: "Fitz" },
User { id: 2, name: "Roy" },
]
usuarios.push(User { id: 3, name: "Cerro" })
print(usuarios.len())
let nombres = usuarios.map(fn(u) => u.name)
print(nombres)
let con_o = usuarios.filter(fn(u) => u.name.lower() == "roy")
print(con_o)
let buscado = usuarios.find(fn(u) => u.id == 1)
print(buscado)
let no_encontrado = usuarios.find(fn(u) => u.id == 99)
print(no_encontrado)
let primero = usuarios.find(fn(u) => u.id == 1)
match primero {
Ok(u) => print("hola, {u.name}!")
Err(e) => print("no debería pasar: {e}")
}
let primer = usuarios[0]
primer.name = "Patagonia"
print(usuarios)
let m = {"a": 1, "b": 2, "c": 3}
print(m.has("a"))
print(m.get("z"))
print(m.keys())
print(m.values())
print(m.len())
print("Hola".upper())
print("MUNDO".lower())
print("hola".len())
Salida:
3
["Fitz", "Roy", "Cerro"]
[User { id: 2, name: "Roy" }]
Ok(User { id: 1, name: "Fitz" })
Err("no encontrado")
hola, Fitz!
[User { id: 1, name: "Patagonia" }, User { id: 2, name: "Roy" }, User { id: 3, name: "Cerro" }]
true
Err("clave no encontrada: z")
["a", "b", "c"]
[1, 2, 3]
3
HOLA
mundo
4
Con métodos y mutación ya tenés todo lo que hace falta para escribir
programas que cambian de estado y usan datos en colecciones de
manera ergonómica. En el próximo capítulo entra Fitz a manejar
errores del programa sin excepciones: el tipo Result, los
constructores Ok y Err, y el operador ? para propagar.
14. Result y manejo de errores¶
Fitz no tiene excepciones. Cuando una operación puede fallar, su
resultado se modela explícitamente con el tipo built-in Result,
que tiene dos variantes: Ok(valor) para éxito y Err(error) para
falla. El caller siempre ve "esto puede fallar" en el tipo, y decide
qué hacer con la falla.
Es el mismo modelo que Rust. En Python y JavaScript, los errores viajan "por el costado" vía excepciones; en Fitz, los errores son valores comunes que viajan por el mismo camino que el resto.
Construir un Result¶
Ok(v) y Err(e) son constructores: envolvés un valor cualquiera
en la variante correspondiente.
fn divide(a, b) {
if (b == 0) {
return Err("división por cero")
}
return Ok(a / b)
}
print(divide(10, 2)) // Ok(5)
print(divide(10, 0)) // Err("división por cero")
Por convención, el inner de un Err suele ser un Str con el
mensaje, pero el lenguaje no lo obliga: podés meter ahí cualquier
valor (un código, una instancia de un tipo de error custom, lo que
quieras). Lo mismo aplica al inner de Ok.
Consumir un Result con match¶
match sobre un Result usa los patrones Ok(v) y Err(e). Cada
uno matchea su variante y bindea el inner al nombre que pongas.
match divide(10, 2) {
Ok(v) => print("resultado: {v}")
Err(e) => print("falló: {e}")
}
// resultado: 5
Reglas a tener en cuenta:
Ok(v)solo matchea si el valor esValue::Resultde variante exitosa. No matchea contra un Int o un Str pelados.- Mismo criterio con
Err(e). - El binding (
v,e) vive solo dentro del cuerpo del brazo, como cualquier binding dematch(cap. 10). - Si querés ignorar el inner, hoy igual hay que poner un nombre
(
Ok(_)también funciona —_queda como una variable basura en el scope del arm). El soporte para_real adentro de Ok/Err es deuda menor; cuando moleste lo agregamos.
El operador ? — propagar el Err¶
Escribir match cada vez que llamás algo que puede fallar es
verboso. Cuando lo único que querés es "si falla, devolver el mismo
error", el operador ? lo hace por vos:
fn find_user(id) {
if (id == 1) {
return Ok(User { id: 1, name: "Fitz" })
}
return Err("usuario no encontrado")
}
fn describe_user(id) {
let u = find_user(id)? // si find_user devuelve Err, describe_user
// corta y devuelve ese mismo Err.
// Si devuelve Ok(u), `u` queda con el User.
return Ok("#{u.id} es {u.name}")
}
print(describe_user(1)) // Ok("#1 es Fitz")
print(describe_user(42)) // Err("usuario no encontrado")
Mentalmente, expr? se lee así:
Pero más corto y encadenable: find_user(id)?.name desempaqueta
primero y después accede al campo.
Result en una variable o print¶
Como cualquier otro valor, un Result se puede asignar, comparar,
imprimir, pasar a otra función. El print muestra el formato
canónico:
La igualdad es estructural: dos Ok(1) son iguales, dos Err("x")
también, y Ok(1) == Err(1) da false.
Result<T, E> con E tipado¶
Anotar el tipo del Err explícitamente con Result<T, E> habilita
errores estructurados accesibles end-to-end:
type ApiError { status: Int, msg: Str }
fn fetch(url: Str) -> Result<Int, ApiError> {
if url == "/health" {
return Ok(200)
}
return Err(ApiError { status: 503, msg: "service unavailable" })
}
// El `e` del Err tipa ApiError, NO Str — fields accesibles.
match fetch("/users") {
Ok(code) => print("status: {code}"),
Err(e) => print("err {e.status}: {e.msg}")
}
// err 503: service unavailable
Funciona bit-a-bit en fitz run y fitz build.
Sintaxis:
- Result<T> (1 arg) — default E = Str, compat con código existente.
- Result<T, E> (2 args) — E concreto: Result<Int, ApiError>,
Result<User, Int> (códigos de error), etc.
Si omitís la anotación del E en una fn que devuelve Result<T> y
hacés Err(MiError {...}), el checker bindea el e como Str por
default. Para que el binding Err(e) tipa como tu tipo custom,
anotá explícitamente el E.
Ver examples/guide/14c-result-tipado.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Err con tipos compuestos: List y Map¶
El E del Result<T, E> puede ser primitivo, una instancia de
un tipo custom, o un compuesto como List<T> / Map<K,V>. Esto
es útil para devolver stacks de errores o errores
estructurados por campo:
// Errores como lista: stack de validaciones acumuladas
fn validar(input: Str) -> Result<Int, List<Str>> {
let errs: List<Str> = []
if input == "" { errs.push("input vacío") }
if input.len() > 10 { errs.push("input demasiado largo") }
if errs.len() > 0 { return Err(errs) }
return Ok(input.len())
}
match validar("") {
Ok(n) => print("ok: {n}"),
Err(errs) => print("{errs.len()} error(es): {errs}")
}
// → 1 error(es): ["input vacío"]
// Errores como map: estructurados por campo
fn validar_obj(name: Str, age: Int) -> Result<Int, Map<Str, Str>> {
let errs: Map<Str, Str> = {}
if name == "" { errs["name"] = "no puede estar vacío" }
if age < 0 { errs["age"] = "debe ser >= 0" }
if errs.len() > 0 { return Err(errs) }
return Ok(age)
}
El binding Err(e) mantiene el tipo concreto, así que podés llamar
.len(), .get(k), indexar, iterar, etc. — toda la API de List/
Map está disponible sobre el value del error.
Ver examples/guide/14d-err-compuestos.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Err con tipos custom¶
El Err acepta cualquier value, no solo Str. En fitz run
preserva el tipo exacto al desempacar:
type ApiError { status: Int, msg: Str }
fn fetch(url: Str) -> Result<Int> {
if url == "/health" {
return Ok(200)
}
return Err(ApiError { status: 503, msg: "service unavailable" })
}
match fetch("/users") {
Ok(c) => print("status: {c}"),
Err(e) => print("err: {e}")
}
// err: ApiError { status: 503, msg: "service unavailable" }
En fitz build, el Err se coerce a String via Display
(el codegen sigue con Result<T, String> pinned). El value se
imprime igual, pero acceder a fields del Err (Err(e) => e.status)
solo funciona en fitz run; en fitz build el e tipa Str y
da error de "field access sobre Str". Workaround portable: imprimir
el Err completo (vía Display) o usar Err(Int) con códigos
numéricos.
? fuera de fn — mensaje propio¶
Cuando un ? en top-level (o adentro de una fn sin -> Result<T>)
recibe un Err, el programa aborta con un mensaje específico
mostrando el contenido del Err:
Antes de Err+ daba `return` solo puede usarse adentro de una
función — frío y engañoso (el ? reusaba el mecanismo de
return internamente). Ahora el usuario ve qué falló.
Ver examples/guide/14b-errores-tipados.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔ fitz build).
Lo que todavía no anda¶
- Chequeo estático de
?—fitz checkexige que el operando de?seaResult<T>y que la función contenedora declare-> Result<...>(a menos que la función esté sin anotación de retorno, donde queda en modo gradual). Errcon bindings tipados en codegen — el bindingedel patternErr(e)siempre tipaStren el código compilado, porque el Err side sigue pinned aResult<T, String>. En el intérprete conserva el tipo original del inner. RefactorearType::Resultpara llevar también el tipo del Err es deuda residual — toca 10+ sitios del checker.
Features de Err completas:
Err(<no-Str>)preserva el tipo enfitz run(Int, Instance, Tuple, etc. al desempacar con match);?en top-level aborta con mensaje específico mostrando el contenido del Err en lugar del genérico "return fuera de función".
Ejemplo completo¶
examples/guide/14-result.fitz:
type User { id: Int, name: Str }
fn divide(a: Int, b: Int) -> Result<Int> {
if (b == 0) {
return Err("división por cero")
}
return Ok(a / b)
}
match divide(10, 2) {
Ok(v) => print("ok: {v}")
Err(e) => print("err: {e}")
}
match divide(10, 0) {
Ok(v) => print("ok: {v}")
Err(e) => print("err: {e}")
}
fn find_user(id: Int) -> Result<User> {
if (id == 1) {
return Ok(User { id: 1, name: "Fitz" })
}
return Err("usuario no encontrado")
}
fn describe_user(id: Int) -> Result<Str> {
let u = find_user(id)?
return Ok("#{u.id} es {u.name}")
}
match describe_user(1) {
Ok(desc) => print(desc)
Err(e) => print("falló: {e}")
}
match describe_user(42) {
Ok(desc) => print(desc)
Err(e) => print("falló: {e}")
}
Salida:
En el próximo capítulo damos un paseo corto por los errores del intérprete: cómo leer un mensaje, qué significa cada uno, y las limitaciones de precisión que todavía tiene. Ojo a la distinción: acá hablamos de errores que maneja tu programa Fitz; allá, de errores que te cuenta el intérprete cuando tu programa está mal escrito.
15. Errores y mensajes¶
Tarde o temprano vas a tipear algo mal y el intérprete te va a cortar.
Este capítulo es un mapa de los errores del intérprete: los que
aparecen cuando tu programa Fitz está mal escrito o intenta algo
inválido en runtime. No los confundas con los errores del
programa — los Err(...) que devuelve una función — que cubrimos
en el cap. 14.
Formato general¶
Un error de Fitz tiene esta forma:
L:C es la línea y la columna donde se detectó el problema. El
intérprete corta la ejecución apenas encuentra el primer error, así
que vas a ver uno por corrida.
De qué fase vino el error¶
Fitz procesa tu programa en cuatro etapas, y cada una puede tirar errores con distinto sabor:
- Lexer — separa el texto en tokens. Si una comilla no cierra o aparece un carácter raro, falla acá.
- Parser — arma el árbol de sintaxis. Si la gramática no
coincide (
ifsin{,matchsin=>, expresión incompleta), falla acá. - Checker estático (Fase 5) — recorre el árbol validando las
anotaciones de tipo y las expresiones. Si declaraste
x: Inty le asignás"hola", si llamásadd(5)cuandoaddespera dos argumentos, o si el callback de.filter(...)no devuelveBool, falla acá. - Evaluador — ejecuta el árbol. Si tu programa pasó las tres etapas anteriores pero hace algo inválido en runtime (dividir por cero, indexar fuera de rango, matchear sin brazo que coincida), falla acá.
Hoy el lexer y el parser dan posiciones precisas. El checker y
el evaluador, en cambio, suelen reportar 0:0 — es deuda explícita:
nos faltan ubicaciones para las subexpresiones. La descripción del
error sí es buena, así que usamos eso para orientarnos hasta que se
mejore.
Modo strict y --no-typecheck¶
fitz run aborta cuando el checker estático encuentra errores.
Eso quiere decir que un programa con errores de tipo no llega
a ejecutarse:
✗ archivo.fitz — 1 error(es) de tipo:
Error — `x` declarado como `Int` recibió un valor `Str`
Usá `fitz check` para revisar, o `fitz run --no-typecheck archivo.fitz` para correr igual.
Si querés saltarte el chequeo (por ejemplo, para probar una rama
mientras todavía hay errores en otra parte del programa, o para
diagnosticar un bug del propio checker), agregá --no-typecheck:
En ese modo los errores del checker se reportan como warnings y el programa se ejecuta igual.
Errores típicos del lexer¶
| Mensaje | Qué pasó | Cómo arreglar |
|---|---|---|
String sin cerrar — salto de línea antes de la comilla de cierre |
Abriste " y llegaste al final de la línea sin cerrar. |
Cerrá las comillas o usá \n (ver cap. 5). |
Ejemplo:
Error en línea 1:5 — String sin cerrar — salto de línea antes de la comilla de cierre
Sugerencia: Usá \n para incluir un salto de línea dentro del string
Errores típicos del parser¶
| Mensaje | Qué pasó |
|---|---|
Se esperaba una expresión, se encontró 'X' |
Faltó la expresión donde el parser la esperaba (después de +, después de =, dentro de paréntesis). |
se esperaba ')' para cerrar la llamada |
Una llamada quedó sin cerrar paréntesis. |
se esperaba '=>' después del patrón |
Brazo de match mal formado, típicamente por un patrón no soportado (cap. 10). |
los struct literals no se permiten directamente en condiciones de if/while/for/match |
Un User { id: 1 } adentro de la condición de un if/while/for/match. Envolvelo en paréntesis (cap. 12). |
se esperaba ',', salto de línea o '}' entre campos del struct literal |
Faltó el separador entre dos campos de una instancia (cap. 12). |
índice fuera de rango: N en lista de tamaño M |
xs[i] con i por fuera de la lista (cap. 9). |
clave no encontrada en mapa: k |
m[k] con clave que no existe (cap. 9). |
el tipo 'X' no soporta indexing con '[]' |
Intentaste [i] sobre algo que no es lista ni mapa (cap. 9). |
Ejemplo:
Errores típicos del checker estático¶
Estos los detectás con fitz check o aparecen al correr fitz run
(que aborta en modo strict por default):
| Mensaje | Cuándo aparece |
|---|---|
variable desconocida \x`` |
Usaste un nombre que no fue declarado. |
`x` declarado como `T` recibió un valor `U` |
Mismatch entre la anotación y el valor. |
la función \f` espera N argumento(s), recibió M` |
Aridad incorrecta en la llamada. |
`return` devuelve `T` pero la función declara `U` |
El cuerpo de la fn devuelve algo distinto a lo declarado. |
el operador \?` requiere un `Result`, recibió `X`| Usaste?` sobre algo que no es Result. |
|
match sobre \Result` no es exhaustivo: falta el caso `X`` |
Match sin cubrir Ok o Err (y sin wildcard). |
el tipo \X` no tiene el método `Y`| Typo de método (xs.lenght()`) o método inexistente. |
|
el tipo \X` no soporta indexing con `[]`|obj[i]` sobre algo que no es lista/mapa. |
Ejemplo:
Errores típicos del evaluador¶
Estos son los que más vas a ver mientras escribís lógica — pasaron el checker porque el sistema de tipos no analiza valores:
| Mensaje | Cuándo aparece |
|---|---|
variable 'x' no definida |
Usaste un identificador antes de asignarle nada. También aparece en interpolaciones (cap. 5). |
operación '+' no soportada entre 'Str' y 'Int' |
Concatenaste tipos distintos sin coerción (cap. 5). Lo mismo para -, *, /. |
división por cero |
Dividiste por 0 (Int) o 0.0 (Float). Cap. 4. |
la condición de 'if' debe ser Bool, no 'Int' |
Pasaste un valor no-Bool a la condición. Lo mismo aplica a while. Cap. 6. |
operando izquierdo de 'and' debe ser Bool, no 'X' |
Igual, en and / or. Cap. 6. |
'add' espera 2 argumento(s), recibió 1 |
Aridad incorrecta al llamar (cap. 11). |
'n' no es invocable (es Int) |
Intentaste llamar como función algo que no lo es. |
'break' solo puede usarse adentro de un loop |
break / continue fuera de un loop. Cap. 8. |
'return' solo puede usarse adentro de una función |
return en el nivel global. Cap. 11. |
el 'match' no matcheó ningún brazo |
El match no tenía wildcard y ningún patrón coincidió. Cap. 10. |
no se puede iterar sobre un valor de tipo 'X' |
for x in v con v que no es List ni Range (cap. 9). |
el tipo 'X' no tiene un campo llamado 'Y' |
Acceso a un campo que no existe, o instanciación con un campo no declarado. Cap. 12. |
falta el campo 'Y' al instanciar 'X' (no tiene default y no es nullable) |
Omitiste un campo obligatorio en un struct literal. Cap. 12. |
tipo 'X' no definido |
Instanciaste un tipo que no fue declarado con type (o lo escribiste mal). Cap. 12. |
acceso a campo '.X' sobre un valor de tipo 'Y' |
Hiciste obj.campo sobre algo que no es una instancia (Int, Str, List, etc.). Cap. 12. |
'Ok' espera exactamente 1 argumento, recibió N |
Constructor Ok / Err con aridad incorrecta (cap. 14). |
el operador `?` requiere un valor `Result`, recibió 'X' |
Usaste ? sobre algo que no es un Result (cap. 14). |
Ejemplo (el archivo de este capítulo):
Cuando el mensaje no alcanza¶
Si el error no te da pistas claras, recordá que el binario hoy imprime los tokens y el AST antes de la ejecución:
--- Tokens ---
1:1 Ident("print")
1:6 LParen
...
--- AST ---
[0] Expr(
Call { ... }
)
--- Ejecución ---
Error en línea 0:0 — ...
Ese dump te dice exactamente cómo el lexer leyó tu código y cómo el
parser lo armó. Para errores de runtime con posición 0:0, ese
contexto suele ser lo que más sirve para ubicar el problema mientras
no tengamos posiciones finas.
Lo que viene¶
- Posiciones de subexpresiones en errores de runtime, para que
desaparezca el
0:0. - Error recovery en el parser: hoy el primer error corta el parseo. Más adelante el parser va a poder seguir y reportar varios errores de una sola corrida.
- Cuadro de contexto debajo del mensaje, con la línea del código fuente subrayando el problema (estilo Rust / Elm).
Ejemplo completo¶
examples/guide/15-errores.fitz:
fitz check lo deja pasar: el sistema de tipos no analiza valores
(no sabe que y siempre vale 0 en este punto). El error aparece
al correr (fitz run):
Si querés ver cómo se ven los errores del checker, probá agregar
let x: Int = "hola" en un archivo y correrlo: fitz run aborta
en strict mode antes de ejecutar. Sumando --no-typecheck los
errores pasan a warnings y el programa sigue corriendo.
Con el mapa de errores del intérprete a mano, podemos pasar a un
tema distinto: cómo partir el código en archivos. Hasta ahora todos
los ejemplos vivieron en un solo .fitz. En el próximo capítulo
vemos cómo separar pedazos y traerlos con import.
16. Módulos¶
Hasta acá, todos los programas vivieron en un archivo. Cuando los
proyectos crecen, querés partir el código: una pieza por archivo,
con sus tipos, funciones y constantes, y traerlos donde haga falta.
Fitz tiene dos formas de hacerlo: import foo para usar el archivo
como namespace, y from foo import a, b para traer nombres directo
al scope.
Tu primer módulo¶
Pongamos dos archivos lado a lado:
utils.fitz:
main.fitz:
Salida:
Lo que pasa:
- Al ver
import utils, el intérprete busca un archivoutils.fitzrelativo amain.fitzy lo evalúa entero, en un scope aislado. utilsqueda bindeado en el scope demain.fitzcomo un módulo: un valor que responde autils.<nombre>con lo que el módulo tenga top-level.utils.greet("Fitz")buscagreetadentro del env del módulo, lo invoca, y devuelve elStrinterpolado. La closure degreetvePREFIXporque está en el mismo env.
from ... import — traer nombres directos¶
Si no querés escribir utils. cada vez, podés pedir nombres
específicos:
main.fitz:
Diferencias con import utils:
from importno expone el módulo como tal — solo bindea los nombres pedidos.- El módulo igual se carga entero (eager): si tiene side effects
top-level, pasan al evaluar el
from. - Si el módulo no exporta uno de los nombres, error explícito al
evaluar el
from.
Trailing comma admitida: from utils import greet, PREFIX, (la
coma final se ignora).
Forma multi-línea con paréntesis¶
Si la lista de imports se hace larga, los paréntesis habilitan la forma multi-línea estilo Python — cada item en su propia línea:
Trailing comma antes del ) opcional. Aliases (as) funcionan
igual que en single-line, también mezclables:
Ver examples/guide/16d-import-multilinea.fitz
para el ejemplo completo (validado bit-a-bit fitz run ↔
fitz build).
Alias con as¶
Tanto import como from import aceptan as <ident> para
renombrar el binding local. Útil cuando:
- el nombre original es largo o choca con un símbolo del archivo
actual (
from foo import PREFIX as REMOTEmientras tenés tu propialet PREFIX = ...); - querés usar un alias corto para un namespace
(
import muy_largo_paquete as p); - el código se lee mejor con el alias (
from db import Connection as Conn).
import utils as u
from utils import greet as saludar, PREFIX as REMOTE
print(saludar("Fitz")) // saludos, Fitz
print(REMOTE) // saludos,
print(u.PREFIX) // saludos,
Una entry de from import puede tener alias o no — se pueden
mezclar: from foo import a as x, b, c as z.
Para un tipo importado con alias, los struct literals se escriben
con el alias (Person { id: 1 }), pero el Display mantiene el
nombre original del tipo (User { id: 1 }) — el alias es local
al archivo importer, no parte de la identidad del tipo. Esto da
paridad bit-a-bit entre fitz run y fitz build.
Paths con puntos — subdirectorios¶
Los segmentos separados por . mapean a subdirectorios:
sub/foo.fitz:
main.fitz:
Reglas:
import sub.fooresuelve a<dir-del-archivo-importer>/sub/foo.fitz.- El binding es el último segmento (
foo), nosub.foo. Para acceder al módulo:foo.one(). No hay un bindingsubque tengafooadentro. from sub.foo import bartambién funciona: misma resolución de path, pero el binding esbardirecto (sin pasar porfoo).
Tipos importados y struct literals¶
Los type declarados en un módulo son valores comunes: se
exportan como cualquier otro. Para usarlos con la sintaxis de
struct literal (User { id: 1, name: "x" }), hay que traer el
tipo al scope con from ... import:
models.fitz:
main.fitz:
from models import User
let u = User { id: 7, name: "Fitz" }
print(u) // User { id: 7, name: "Fitz" }
print(u.name) // Fitz
Por qué hace falta el from import: el parser de struct literal
espera un Ident { ... } simple. import models +
models.User { ... } no parsea hoy — el parser no sabe que
models.User es el "type name". El from import te trae User
directo y resuelve el problema sin extender el parser. (Asimetría
que se va a cerrar cuando moleste.)
Aislamiento¶
Cada módulo tiene su propio env. Las variables, funciones y tipos
top-level del módulo viven ahí; el scope del importer NO ve esas
definiciones salvo lo que se traiga vía import o from import.
Eso significa que dos módulos pueden tener nombres iguales sin chocar:
a.fitz:
b.fitz:
main.fitz:
Y las closures de funciones exportadas siguen viendo el env
del módulo donde se definieron, no el del importer. Por eso
greet en el primer ejemplo encontró PREFIX aunque PREFIX no
está en el scope de main.fitz.
Cache — el mismo archivo no se ejecuta dos veces¶
Si dos partes del proyecto importan el mismo archivo, se carga una sola vez. La segunda vez devuelve el módulo ya cargado, sin re-evaluar el body:
import utils // primera vez: utils.fitz se evalúa
import utils // segunda vez: hit en cache, no se re-evalúa
Esto es importante si tu módulo tiene side effects top-level (un
print o un let con cómputo): pasan una sola vez. La identidad
del módulo es la misma — si guardás el resultado de los dos
imports en dos variables, son iguales (u1 == u2).
Ciclos¶
Un módulo no puede importar (directa o transitivamente) un módulo que todavía no terminó de cargarse. El intérprete detecta el ciclo y corta con un error explícito:
a.fitz:
b.fitz:
main.fitz:
Salida:
(Los paths van canonicalizados — vas a ver los absolutos.)
No intentamos resolver ciclos automáticamente: son raros en código bien organizado y agregan complejidad sin payoff. Si te encontraste con uno, reorganizá: típicamente significa que las dos piezas querían ser una sola, o que hay un tercer módulo "core" que las dos tendrían que importar.
Qué se exporta¶
Hoy, todo lo top-level del módulo es público: variables,
funciones, tipos. No hay marcador pub ni convención de
underscore. Si por ahora querés indicar que algo es "interno",
ponele un nombre claro (_helper) por convención — pero no hay
chequeo del intérprete.
Cuando aparezca la necesidad real (módulos con superficie pública
pequeña y privada grande), se va a sumar pub explícito o
convención de underscore validada por el compilador estático.
Constantes del módulo con RHS calculada¶
Desde la mini-tanda F14, los let X = <expr> top-level del
módulo soportan RHS arbitrarias (no solo literales). El compilador
elige automáticamente entre dos formas:
-
Const-eval — RHS reducible a un valor Rust constante (literales más BinOp/UnaryOp aritmético/lógico/bit sobre operands const-eval): se emite como
pub const X: T = <rhs>en el módulo Rust generado. Cero overhead, el valor se inlinea como cualquier constante Rust. -
Runtime — RHS no const-eval (call a una fn, struct lit, concat de strings, field access, etc.): se emite como accessor function
pub fn X() -> T { <rhs> }. Cada referenciamod.XoX(trasfrom mod import X) se traduce a una llamadaX()que re-evalúa la RHS. Útil para inicializar valores compuestos que no entrarían en unaconstRust.
examples/guide/16b-modulos-let-expr.fitz:
import module_let_expr_utils as utils
print(utils.SECONDS_PER_HOUR) // 3600 — pub const inlineado
print(utils.MAX_USERS) // 100 — accessor fn (depende de const)
print(utils.DEFAULT_USER) // accessor fn que devuelve User
print(utils.GREETING) // accessor fn (Str concat)
examples/guide/module_let_expr_utils.fitz:
let SECONDS_PER_HOUR: Int = 60 * 60 // const-eval
let MAX_USERS: Int = SECONDS_PER_HOUR / 36 // accessor (referencia ident)
type User { id: Int = 0, name: Str = "anon" }
fn make_user() -> User => User {}
let DEFAULT_USER: User = make_user() // accessor (call)
let GREETING: Str = "¡hola, " + "Fitz!" // accessor (concat Str)
Detalle: una RHS const-eval que referencia otra const del mismo
módulo (como MAX_USERS arriba) cae al camino accessor por
simplicidad — el codegen no propaga const-ness entre lets del
módulo. En la práctica no importa: la diferencia entre pub const
y pub fn X() es invisible para el código que llama a mod.X.
Imports transitivos¶
Los módulos pueden tener sus propios import — la cadena se sigue
recursivamente. Esto vale tanto para fitz run como para
fitz build (mini-tanda F15 cerró el último gap del codegen).
examples/guide/16c-modulos-transitivos.fitz muestra una fachada que organiza dependencias en árbol:
// 16c-modulos-transitivos.fitz
import transitivos_app
print(transitivos_app.welcome(42, "Fitz")) // bienvenido USER#42:FITZ
// transitivos_app.fitz — fachada
from transitivos_models import User // ← transitivo
from transitivos_format import format_user // ← transitivo
fn welcome(id: Int, name: Str) -> Str {
let u = User { id: id, name: name }
return "bienvenido {format_user(u)}"
}
// transitivos_format.fitz — helper
from transitivos_models import User // ← cadena profunda
fn format_user(u: User) -> Str {
return "USER#{u.id}:{u.name.upper()}"
}
Detalles del loader:
- Ciclos detectados con un stack de paths en curso:
a → b → aaborta conciclo de imports detectado: ...\a.fitz -> ...\b.fitz -> ...\a.fitz(paralelo bit-a-bit al evaluator). - Cache compartida: si dos módulos transitivos importan el mismo archivo, se carga una sola vez.
from python importadentro de módulos transitivos sí se soporta: cada módulo puede declarar sus propios imports Python sin obligar al main a participar. El codegen reusa los helpers del preludio Python del crate root viause crate::__fitz_py_*y emite statics + getters locales por módulo (pyo3 cachea viasys.modules, así que el OnceLock duplicado es cero overhead real). Patrón canónico: librerías Fitz que delegan operaciones a Python (numpy/scipy/sqlalchemy/redis-py) sin filtrar el detalle a quien las usa. Ver examples/python-interop-modular.fitz.
Qué no se puede hacer todavía¶
foo.User { ... }— el struct literal con namespace no parsea. La forma actual esfrom foo import User.stdlib(from fitz import http) — el prefijofitz/se reserva para Fase 4 cuando entre HTTP nativo. Hoy todo es código de usuario.fitz buildsoporta módulos con la única restricción de inferencia: las funciones del módulo deben anotar tipos de parámetros y retorno (limitación heredada de codegen 5b.1; la inferencia de tipos de params es deuda residual).
Ejemplo completo¶
examples/guide/16-modulos.fitz:
import guide_utils
from guide_utils import User
let u = User { id: 7, name: "Fitz" }
print(guide_utils.greet(u.name))
print(u)
examples/guide/guide_utils.fitz:
let PREFIX = "saludos, "
fn greet(name: Str) -> Str => "{PREFIX}{name}"
type User {
id: Int
name: Str
}
Por qué guide_utils.fitz y no 16-utils.fitz: el binding que
produce import es el último segmento del path, y tiene que ser
un identificador válido. 16-utils arranca con dígito y contiene
-, así que no se puede usar como nombre de variable en Fitz. El
auxiliar va con nombre limpio.
Salida (los paths del dump de tokens/AST se omiten):
Con módulos cubrimos cómo partir el código adentro de un mismo
proyecto. Lo que sigue es cómo armar proyectos — el
package manager (fitz.toml, deps,
lockfile, fitz new/add/remove). Después de eso entramos a
HTTP nativo, que es donde Fitz se diferencia.
16b. Package manager¶
Hasta acá los ejemplos vivieron en archivos sueltos: fitz run
mi_archivo.fitz. Para proyectos reales (varios archivos,
dependencias compartidas, binarios distribuibles), Fitz tiene un
package manager built-in. Patrón Cargo: manifest fitz.toml,
lockfile fitz.lock, sub-comandos para crear/agregar/quitar/
actualizar deps.
El manifest fitz.toml¶
Un proyecto Fitz arranca con un archivo fitz.toml en su raíz:
[package]
name = "miapp" # ident usable en Fitz (sin hyphens)
version = "0.1.0"
edition = "2026"
[bin]
main = "src/main.fitz" # entry point del binario
[dependencies]
# (vacío por ahora — agregamos después)
Las tres secciones obligatorias son [package] (metadata),
una de [bin] (para programas) o [lib] (para librerías
importables), y [dependencies] (puede ir vacío).
fitz new / fitz init — scaffolding¶
# Crea un proyecto nuevo en una carpeta nueva
fitz new miapp
# Inicializa un proyecto en el cwd actual (sin crear carpeta)
fitz init
fitz new <nombre> arma:
miapp/
├── fitz.toml # con [bin] main = "src/main.fitz"
├── .gitignore
└── src/
└── main.fitz # template hello world
También corre git init (a menos que pases --no-git). El
nombre debe matchear ^[a-z][a-z0-9_-]{0,63}$ (crates.io style),
con el caveat de que si querés que sea importable desde código
Fitz no podés usar hyphens (el parser no admite - en
identificadores). Para una lib usable, miapp ✓, mi-app ✗.
Flag --http cambia el template a un server HTTP mínimo
(@get("/") que devuelve "Hola"). --no-git evita el
git init.
Manifest mode: fitz run / fitz build / fitz check sin args¶
Cuando estás adentro de un proyecto, el CLI lee el fitz.toml
automáticamente:
cd miapp/
fitz run # corre [bin].main (o [lib].entry si no hay bin)
fitz build # compila a target/release/miapp{.exe}
fitz check # chequea tipos del [bin].main
El CLI hace walk-up Cargo-style: busca fitz.toml desde el
cwd y va subiendo carpetas hasta encontrar uno. Útil si corrés el
comando desde src/ o subcarpetas más profundas.
Dependencias path¶
La forma más simple de compartir código entre proyectos locales es path deps:
greetings apunta a un proyecto vecino que declara [lib]
entry = "src/lib.fitz". Desde tu código Fitz:
El loader resuelve from greetings import X consultando primero
el dep_registry construido del fitz.toml, después fallback a
paths relativos. Caveat: el nombre de la dep debe ser usable como
identificador Fitz (sin hyphens) porque eso es lo que escribís
en el import.
Dependencias git¶
Para deps remotas:
[dependencies]
fitz-foo = { git = "https://github.com/algun/foo.git", tag = "v0.2.0" }
otra = { git = "https://github.com/algun/otra.git", rev = "abc123" }
Acepta tag (release pinned) o rev (commit SHA exacto) —
mutuamente exclusivos. No acepta branch porque branches se
mueven (no reproducible). El primer fitz run clona la dep al
cache local (~/.fitz/cache/git/<sanitized-url>@<ref>/) y la
reusa en corridas siguientes. fitz update <name> invalida el
cache y re-clona (útil cuando el tag upstream se actualiza).
Lockfile fitz.lock¶
Cada fitz run/build/check actualiza fitz.lock con la
versión resuelta exacta de cada dep:
version = 1
[[package]]
name = "greetings"
version = "0.1.0"
[[package]]
name = "fitz-foo"
version = "0.2.0"
source = "git+https://github.com/algun/foo.git#abc123def..."
Idempotente: si el manifest no cambió, no se reescribe. Para path
deps no hay source (son determinísticas por path). Para git
deps el source incluye el commit hash exacto, garantizando que
re-clones siempre traen el mismo árbol.
Convención: commiteás fitz.lock en binarios ([bin]), lo
dejás fuera en librerías ([lib]) — igual que Cargo.
fitz add / remove / update¶
Para no editar el fitz.toml a mano:
# Agregar dep path (relativa al manifest)
fitz add greetings --path ../greetings
# Agregar dep git con tag (o --rev <sha>)
fitz add fitz-foo --git https://github.com/algun/foo.git --tag v0.2.0
# Quitar una dep
fitz remove greetings
# Re-resolver deps (invalida cache git, re-clona)
fitz update # todas
fitz update fitz-foo # solo una
fitz add preserva comentarios y formato del fitz.toml
original (vía toml_edit). Si la dep ya existe con el mismo
nombre, sobreescribe sin preguntar (cargo-style). Para revertir,
fitz remove.
Lo que NO anda todavía¶
- Registry público (
fitz publish/fitz add foo@1.2.3sin path ni git) — la decisión de hosting + infra queda diferida. Path + git deps cubren el 90% del caso real. Cuando aparezca demanda concreta, sub-fase dedicada. - Dev-dependencies (
[dev-dependencies]) — diferidas como sub-paso futuro. Hoy todo dep va en[dependencies]y se carga siempre. - Workspaces (multi-proyecto con
Cargo.tomlvirtual del root) — sub-paso futuro si aparece presión. - Branches en git deps — solo
tagorev(reproducible). - Transitive deps — el dep_registry hoy es flat: si tu lib declara una dep, el bin que importa tu lib NO la ve heredada. Workaround: declarar la dep también en el bin.
Ejemplo ejecutable¶
examples/guide/16b-pkg-manager/
tiene un mini-proyecto con dos paquetes — greetings (lib) y
greeter (bin que importa via path dep). Ver el README ahí para
el flujo completo. Test cli_e2e
(cap_16b_ejemplo_greeter_corre_y_genera_lockfile) valida que
todo el flow funciona end-to-end.
17. HTTP nativo¶
Hasta acá Fitz fue un lenguaje "normal" — tipos, control de flujo,
funciones, módulos. El capítulo de HTTP es donde se nota el
diferencial: no hay librería que importar, no hay framework que
inicializar. Decorás una función con @get o @post, corrés el
archivo con fitz run, y hay un servidor HTTP escuchando.
Tu primer endpoint¶
hola_server.fitz:
Corrélo:
Salida en consola:
En otra terminal:
Respuesta:
Para bajarlo, Ctrl-C en la terminal del server. El intérprete termina de procesar las requests en vuelo y cierra limpio.
Lo que pasó:
- Al ver
@get("/")arriba defn index, el intérprete registra la ruta en una tabla interna. La fn queda definida como cualquier otra; el decorator solo asocia método + path con ella. - Al terminar de evaluar el archivo, el intérprete nota que hay
rutas registradas y arranca el server en
127.0.0.1:3000. - Cuando llega un
GET /, el server llama aindex()con los args que correspondan (en este caso ninguno), serializa el valor de retorno a JSON, y responde 200.
Verbos: @get, @post, @put, @delete¶
Cada decorator HTTP toma un único argumento: la ruta. Mismas reglas para todos:
@get("/users")
fn list_users() => []
@post("/users")
fn create_user(body) => body
@put("/users/{id}")
fn update_user(id: Int, body) => body
@delete("/users/{id}")
fn delete_user(id: Int) => "ok"
Si un decorator no es de los cuatro de arriba (ni @server), el
intérprete corta con error explícito.
Path params: /users/{id}¶
Las llaves dentro del path son params:
El nombre adentro de {...} tiene que coincidir con un parámetro
de la función. El tipo declarado del parámetro decide cómo se
convierte:
id: Int→ el path param se parsea como entero. Si la URL trae/users/abc, la respuesta es 400 con{"error":"path param 'id': se esperaba Int, recibió 'abc'"}.id: Float,id: Bool,id: Str— mismas reglas, según el tipo.- Sin anotación: llega como
Str.
Una ruta puede tener varios path params:
Repetir un nombre (/a/{x}/b/{x}) es error al registrar.
Body: JSON deserializado a un type¶
Para handlers que reciben datos (típicamente POST y PUT), declarás un parámetro extra cuyo nombre no está en el path. Ese parámetro es el body:
type UserInput {
name: Str
email: Str?
}
@post("/users")
fn create_user(body: UserInput) {
return User { id: 1, name: body.name, email: body.email }
}
Cuando llega un POST /users con
{"name":"fitz","email":"fitz@example.com"}:
- El intérprete parsea el body como JSON.
- Lo valida contra el
type UserInput: cada campo declarado tiene que estar (con default o ser nullable, o presente). - Construye una
Value::Instancey la pasa comobodyal handler.
Si el JSON no parsea, o falta un campo no nullable sin default, o trae un campo extra, 400 con un mensaje claro:
{"error":"body para 'UserInput': falta el campo 'name'"}
{"error":"body para 'UserInput': campo no declarado: age"}
{"error":"body no es JSON válido: ..."}
Reglas de la convención:
- Cualquier parámetro del handler que no está en
path_paramscuenta como body. - Máximo uno por handler. Más de uno → error al registrar.
- Si el body no tiene anotación de tipo (
fn h(body)), llega comoValuelibre —Map<Str,Value>para un objeto,List<Value>para un array, etc. Útil para webhooks o APIs sin schema fijo.
Mezclando path params y body:
@put("/users/{id}")
fn update(id: Int, body: UserInput) {
return User { id: id, name: body.name, email: body.email }
}
id viene del path, body del cuerpo de la request.
Body: application/x-www-form-urlencoded (mini-tandas MP + UC)¶
Los handlers también aceptan bodies tipo key=value&key2=value2 —
el formato que mandan los browsers cuando un <form> HTML usa
method="POST" sin enctype:
@post("/login")
fn login(body: Map<Str, Str>) -> Str {
let user = body["username"]
let pass = body["password"]
return "hola {user}"
}
El runtime detecta el header Content-Type: application/x-www-form-urlencoded
y parsea el body a un Map<Str, Str> (paralelo a JSON). URL-decoding
aplicado: + → espacio, %XX → byte hex.
Funciona end-to-end tanto en fitz run como en fitz build —
paridad bit-a-bit.
Body: multipart/form-data¶
Cuando un <form> HTML usa enctype="multipart/form-data"
(necesario para uploads de archivos), el body llega como múltiples
"parts" delimitados por un boundary. Fitz lo parsea automáticamente
a un Map<Str, ...> donde cada entry es:
- Text field (sin
filenameenContent-Disposition) →Value::Strcon el contenido. - File field (con
filename) → instancia del tipo built-inFileconname: Str?,content_type: Str?ycontent: Str.
@post("/upload")
fn upload(body: Map<Str, File>) -> Str {
let f = body["doc"]
if (f.name == null) {
return "sin filename"
}
return "subiste '{f.name}' ({len(f.content)} caracteres)"
}
curl -X POST http://127.0.0.1:3000/upload \
-F "doc=@notas.txt"
# "subiste 'notas.txt' (123 caracteres)"
Limitación MVP: el content de los files es Str, así que solo
se admiten files cuyo contenido sea UTF-8 válido. Files binarios
(imágenes, PDFs, zips) hacen el parse fallar con 400. Soporte de
binarios via Value::Bytes es deuda residual.
Paridad fitz run ↔ fitz build: el binario nativo también
acepta multipart. Los helpers
__parse_multipart y __extract_multipart_boundary se emiten en el
preludio HTTP del codegen, paralelos a los del intérprete. El
dispatcher de Content-Type del wrapper async incluye la rama
multipart con el mismo flow de 400 (sin boundary) / 415 (CT no
soportado).
File es un nominal built-in del runtime HTTP (igual que Request y
Response). No hace falta declararlo ni importarlo.
Ejemplo runnable completo: examples/guide/17c-multipart.fitz.
Respuestas: serialización JSON automática¶
Lo que devolvés del handler se serializa a JSON, sin que tengas que tocar nada:
Value que devolvés |
JSON | Status |
|---|---|---|
Int, Float, Str, Bool, Null |
el primitivo | 200 |
List(...) |
array | 200 |
Map(...) con claves Str |
object | 200 |
Instance de un type |
object con campos en orden de declaración | 200 |
Ok(v) |
v serializado |
200 |
Err(e) |
{"error": e} |
500 |
Por eso podés escribir el handler como cualquier función Fitz:
fn divide(a: Float, b: Float) -> Result<Float> {
if b == 0.0 {
return Err("división por cero")
}
return Ok(a / b)
}
@get("/half/{n}")
fn half(n: Float) {
return divide(n, 2.0)
}
GET /half/10 → 200 con 5.0. GET /half/0 (si lo armaras para
fallar) → 500 con {"error":"división por cero"}.
Tipos que no se pueden serializar (funciones, tipos opacos, módulos, rangos) generan 500 con un mensaje explícito. No te va a pasar por accidente — pasaría si devolvieras una función entera, por ejemplo.
@server(port, host) — configurar el server¶
Por default el server escucha en 127.0.0.1:3000. Para cambiar
puerto o host, decorá una fn con @server:
@server(8080, "0.0.0.0")
fn main() => 0
@get("/")
fn index() => "escuchando en todas las interfaces"
Reglas:
- Args positional: primero
port: Int, despuéshost: Str. Cualquiera se puede omitir (@server(8080)deja el host default). porttiene que estar en[1, 65535]. Fuera → error al registrar.hosttiene que parsear como IP literal (IPv4 o IPv6). No hay resolución DNS —"localhost"no funciona, usar"127.0.0.1".- Solo un
@serverpor programa. Dos → error con el config previo.
La fn que decora @server queda definida en el env como cualquier
otra: no se ejecuta automáticamente. La convención es ponerlo
sobre un placeholder como fn main() => 0, pero el nombre no es
mágico.
Result + ? = handlers limpios¶
Por la regla de "Result se desempaqueta automático", podés usar
el operador ? adentro del handler para propagar errores hacia
la respuesta 500:
type User { id: Int, name: Str }
let users = [
User { id: 1, name: "ana" },
User { id: 2, name: "luis" },
]
fn find_user(id) {
let found = users.find(fn(u) => u.id == id)
return match found {
Ok(u) => Ok(u)
Err(_) => Err("usuario {id} no encontrado")
}
}
@get("/users/{id}")
fn get_user(id: Int) {
let u = find_user(id)?
return u
}
GET /users/1 → 200 con {"id":1,"name":"ana"}.
GET /users/99 → 500 con {"error":"usuario 99 no encontrado"}.
El ? corta la fn devolviendo Err(...), el runtime lo destila a
status 500 con {"error": e}. Sin try/catch, sin if-not-found,
sin hacer nada raro.
Detalle de sintaxis: hoy un brazo de match admite una expresión
como cuerpo, no un statement. Es decir, **return adentro de un
brazo no parsea**: tenés que hacerreturn match { ... }` y poner
el valor directo en cada brazo, como en el ejemplo. Está en la
lista de deuda del lenguaje; cuando se cierre, ambas formas van
a funcionar.
Qué pasa adentro¶
Mientras corre, Fitz tiene un solo runtime tokio multi-thread que comparte el intérprete y axum. Cada request entra como una task tokio independiente, axum la dispatchea a un worker, y el worker invoca el handler Fitz directamente. Las requests corren en paralelo entre workers — un handler lento no bloquea a los demás.
Esto es post-Fase F17 (2026-05-14). Antes Fitz tenía dos threads
con un bridge mpsc/oneshot entre el intérprete sync y tokio
async — esa indirección la metió la Fase 4 porque los Value
usaban Rc<RefCell<>> no-Send. F17 migró los containers a
Arc<parking_lot::Mutex<>>, lo que destrabó:
- Send completo en todo el evaluator (
Value/EnvRef). - Eliminación del bridge (~269 LoC menos en
http.rs). - Paralelismo HTTP real: 5 requests concurrentes a un
handler
sleep(1000).awaitresponden en ~1.2s en lugar de ~5s (medido). Ver examples/guide/19b-paralelismo.fitz.
Qué todavía no anda¶
- Streaming de respuestas — hoy las respuestas se serializan completas antes de mandarse. Server-sent events y descargas grandes están en el roadmap.
- WebSockets — disponibles vía
@ws("/path")+WsConn<T>(ver cap 29), no como parte de los decoradores HTTP del cap 17. - Multipart bodies —
multipart/form-data(típicamente uploads con files) queda como sub-paso futuro cuando aparezca presión real. Cualquier otro Content-Type que no sea JSON o urlencoded recibe 415 con mensaje claro. - Middleware wrap-style con
nextcallable — el modelo donde el middleware controla la invocación del handler (fn mw(req, next) -> Response) queda como sub-paso futuro. Sí está soportado el modelo post-process: un middleware con 2 args(Request, Response)corre DESPUÉS del handler y puede modificar el body, agregar headers, etc. Funciona end-to-end enfitz runyfitz build. Limitación: handlers-> Result<T>+ post mws no compila enfitz buildtodavía (sub-paso futuro adicional).
Features completas del cap HTTP: async/await reales en handlers, paralelismo HTTP real (multi-threaded), status codes custom (
return 401 { ... }), query params (?page=1&size=10), headers de request con@header(name="X"), kwargs en decoradores (@server(docs=false)), middleware (@middleware(fn)) + CORS con preflight automático, state HTTP compartido. Status codes específicos de cadaErraparecen en el schema OpenAPI cuando el handler hacereturn Err(X { status: 404, ... })con un literal Int o con un Ident que apunta a una const top-level Int. Validación de Content-Type estricta — body no-JSON ni urlencoded recibe 415 con msg claro.application/x-www-form-urlencoded— bodies tiponame=Fitz&age=25se parsean comoMap<Str, Str>automáticamente (URL-decoding aplicado a keys y valores). Return type inference en handlers parafitz build—fn create(u: User) { ... return User { ... } }no exige-> Userexplícito; el codegen infiere del body usando el TypeInfo del checker. El cap incluye sub-secciones propias para Status codes custom, Query params y Middleware y CORS más abajo.
Ejemplo completo¶
@server(3000)
fn main() => 0
type User {
id: Int
name: Str
email: Str?
}
type UserInput {
name: Str
email: Str?
}
let users = [
User { id: 1, name: "ana", email: "ana@x.com" },
User { id: 2, name: "luis", email: null },
]
@get("/")
fn index() -> Str => "Fitz HTTP corriendo"
@get("/users")
fn list_users() -> List<User> => users
@get("/users/{id}")
fn get_user(id: Int) -> Result<User> {
let found = users.find(fn(u) => u.id == id)
return match found {
Ok(u) => Ok(u)
Err(_) => Err("usuario {id} no encontrado")
}
}
@post("/users")
fn create_user(body: UserInput) -> User {
let new_id = users.len() + 1
let u = User { id: new_id, name: body.name, email: body.email }
users.push(u)
return u
}
Las anotaciones de return ayudan a hacer el contrato del API
explícito, pero no son obligatorias: tanto el intérprete como
el compilador (fitz build) las infieren desde call sites + body
si están ausentes. La diferencia es estilo — anotaciones siguen
siendo recomendadas para fns públicas (mejor doc + diagnostics
más claros).
Levantalo con fitz run (sin compilar):
O compilalo a binario nativo:
Y probalo:
curl http://127.0.0.1:3000/
# "Fitz HTTP corriendo"
curl http://127.0.0.1:3000/users
# [{"id":1,"name":"ana","email":"ana@x.com"},{"id":2,"name":"luis","email":null}]
curl http://127.0.0.1:3000/users/1
# {"id":1,"name":"ana","email":"ana@x.com"}
curl http://127.0.0.1:3000/users/99
# {"error":"usuario 99 no encontrado"}
curl -X POST http://127.0.0.1:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"sofi"}'
# {"id":3,"name":"sofi","email":null}
curl http://127.0.0.1:3000/users
# (ahora sofi está adentro)
Las modificaciones de users.push(...) persisten entre requests
porque users es una List (compartida por referencia) y todos
los handlers cierran sobre el mismo env del módulo top-level.
Sin estado externo, sin base de datos: la "memoria" del server
es el env del programa. Para producción real querés persistir
en disco o en una DB; para prototipos y juguetes, alcanza.
Status codes custom¶
Por default, el runtime mapea el retorno del handler así:
- Cualquier valor (
Str,Int,Instance, ...) → status 200. Result<T>::Ok(v)→ 200 convserializado.Result<T>::Err(e)→ 500 con{"error": "<e>"}.
Para devolver otro status code, Fitz tiene sintaxis dedicada:
return <status> <body> adentro del handler. El status es un
literal Int (rango 100-599); el body es cualquier expresión
serializable a JSON (map literal, struct, valor primitivo).
@get("/protected") fn protected() -> Str {
return 401 {"message": "no autorizado"}
}
@get("/users/{id}") fn get_user(id: Int) -> Str {
if (id == 1) {
return "alice" // 200 (default)
}
return 404 {"error": "no encontrado"}
}
curl -i http://127.0.0.1:3000/protected
# HTTP/1.1 401 Unauthorized
# {"message":"no autorizado"}
curl -i http://127.0.0.1:3000/users/1
# HTTP/1.1 200 OK
# "alice"
curl -i http://127.0.0.1:3000/users/2
# HTTP/1.1 404 Not Found
# {"error":"no encontrado"}
Reglas:
return <int> { ... }solo funciona adentro de un handler HTTP (@get/@post/@put/@delete). Afuera, el checker lo rechaza con error claro.- El body es obligatorio. Para "no content" (204), usá
{}explícito:return 204 {}. - El status puede ser un literal Int o un identificador que
apunta a una constante top-level Int. El parser distingue por
la forma del body:
{"key": ...}(Str primero) → ReturnStatus;{key: ...}(Ident primero) → struct literal.return 200 user(sin braces) sigue siendo unReturnnormal. - El return type formal del handler se ignora en este path —
un handler
-> Strpuede mezclarreturn "ok"conreturn 404 { ... }en la misma fn.
Las claves del map literal van entre comillas dobles porque la
sintaxis de map literal en Fitz exige que la key sea un valor
({"x": 1}), no un identificador ({x: 1} lee la variable x).
Status codes vía constantes:
Si tenés varios handlers que usan los mismos códigos, nombrarlos mejora la legibilidad y permite que el schema OpenAPI los detecte:
let NOT_FOUND = 404
let UNAUTHORIZED = 401
@get("/protected")
fn protected() -> Str {
return UNAUTHORIZED {"message": "no autorizado"}
}
@get("/users/{id}")
fn get_user(id: Int) -> Str {
if (id == 1) {
return "alice"
}
return NOT_FOUND {"error": "no encontrado"}
}
Solo let X = <Int literal> top-level califica. Una variable local,
un cálculo o un argumento del handler no se resuelven al schema
— el schema cae al 500 default histórico. Para resolver el patrón
return Err({status: NOT_FOUND, ...}) (con un type propio para
errores), ver cap 18.
Query params¶
Para recibir parámetros de la query string (?limit=10&offset=20),
declarálos adentro del path del decorator con la misma sintaxis
de path params, pero después de un ?:
@get("/items?limit={limit}&offset={offset}")
fn list_items(limit: Int, offset: Int) -> List<Item> {
// limit y offset llegan ya tipados como Int
...
}
Cada {name} adentro del query corresponde a un parámetro del
handler con el mismo nombre. La key del query y el nombre del
parámetro deben coincidir — ?l={limit} es error.
Obligatorios vs opcionales:
limit: Int→ obligatorio. Si la query no incluyelimit=..., la response es 400 con{"error": "query param 'limit': falta — es obligatorio"}.limit: Int?→ opcional. Si falta, el handler venull.
@get("/items?name={name}&limit={limit}")
fn search(name: Str, limit: Int?) -> Str {
// name es obligatorio; limit puede llegar null
if (limit == null) {
return "buscando '{name}' sin límite"
}
return "buscando '{name}' con límite {limit}"
}
curl "http://127.0.0.1:3000/items?name=fitz"
# "buscando 'fitz' sin límite"
curl "http://127.0.0.1:3000/items?name=fitz&limit=10"
# "buscando 'fitz' con límite 10"
curl "http://127.0.0.1:3000/items?limit=10"
# {"error":"query param 'name': falta — es obligatorio"}
Tipos soportados en query params: Int, Float, Str,
Bool (los primitivos), opcionalmente nullables (Int?, etc.).
List<T> y tipos custom no se soportan todavía — irían como
body, no como query param.
Coerción: los valores de query siempre llegan como String
desde HTTP. Fitz los parsea al tipo declarado. Si el parse falla
(limit=abc con limit: Int), 400 con el mensaje claro.
Combinable con path params y body: una ruta puede tener los tres a la vez.
type Patch { value: Int }
@put("/items/{id}?dry_run={dry_run}")
fn update_item(id: Int, dry_run: Bool, body: Patch) -> Str {
// id ← path, dry_run ← query, body ← JSON del request
...
}
Middleware y CORS¶
Hasta acá los handlers responden directamente. Para todo lo que pasa
antes del handler — logging, autenticación, rate limiting, CORS —
Fitz tiene middleware: funciones que se apilan sobre un handler
con @middleware(fn) y se ejecutan en orden top-down.
Sintaxis:
fn logger(req: Request) {
// no devuelve nada → la cadena continúa
}
fn auth(req: Request) {
if (req.headers.has("authorization")) {
return null
}
return 401 {"error": "falta header Authorization"}
}
@middleware(logger)
@middleware(auth)
@get("/admin")
fn admin() -> Str => "datos administrativos"
Reglas:
- Los
@middleware(...)deben apilarse antes del decorator de ruta (@get/@post/@put/@delete). - Cada middleware recibe un único arg
Request(built-in con fieldsmethod: Str,path: Str,headers: Map<Str, Str>). Los headers llegan con las keys en lowercase. - Modelo gate-only: el middleware puede cortar la cadena con
return <status> { ... }, o dejarla seguir devolviendonull(o sin return explícito al cierre del body). Cualquier otro valor de retorno es error. - El orden de ejecución es top-down: el
@middleware(...)más arriba corre primero. El último corre justo antes del handler.
Variantes (mini-tandas Mw.next + Mw-Wrap):
Hay tres clases de middleware según los args:
| Aridad | Tipo del 2do param | Kind | Cuándo corre |
|---|---|---|---|
| 1 | (solo Request) |
Pre | ANTES del handler (gate-only) |
| 2 | Response |
Post | DESPUÉS del handler |
| 2 | Fn() -> Response |
Wrap | ENVUELVE el handler |
- Pre (clásico): ejemplo
autharriba. Útil para auth, rate limiting, logging básico. - Post: recibe
(req, resp)y devuelve una Response modificada. Útil para agregar headers, modificar el body, logging con la response final. - Wrap: recibe
(req, next)dondenextes un callableFn() -> Response. El middleware decide cuándo (o si) llamarnext(). Habilita medir tiempo de toda la chain, decisión condicional, response wrapping.
Ejemplo de Wrap:
fn timing(req: Request, next: Fn() -> Response) -> Response {
let r = next() // ejecuta el resto: wraps, handler, posts
return 200 {"wrapped": true, "method": req.method}
}
@middleware(timing)
@get("/api")
fn api() -> Str => "data"
Limitación de fitz build: los Wrap mws corren solo en
fitz run. El binario nativo rechaza con un msg claro citando
fitz run como workaround. Los Pre y Post mws sí compilan en
fitz build. Ejemplo completo en
examples/guide/17d-middleware-wrap.fitz.
CORS:
Para servir APIs a un frontend (Vue, React, etc.) que vive en otro
dominio hace falta CORS. Fitz trae un built-in cors(...) que se
aplica como un middleware más:
cors() sin args usa defaults permisivos: allow_origin: "*",
métodos GET/POST/PUT/DELETE/OPTIONS, headers content-type y
authorization. Para overrides, pasale un Map con las keys que
quieras pisar:
@middleware(cors({
"allow_origin": "https://app.example.com",
"allow_methods": ["GET", "POST"],
"max_age": 3600
}))
@get("/api/items")
fn list_items() -> List<Item> => items
Keys soportadas:
allow_origin: Str(default"*").allow_methods: List<Str>(default métodos comunes).allow_headers: List<Str>(default["content-type", "authorization"]).max_age: Int(default ausente — el browser usa su cache default).
Cuando aplicás cors(...):
- El runtime registra un handler OPTIONS automático para el
mismo path. Una request preflight
OPTIONS /api/itemsresponde 204 No Content con los headersAccess-Control-Allow-*ya configurados, sin tocar tu handler. - Las responses reales del handler (
GET /api/items,POST ...) llevan los headersAccess-Control-Allow-Originetc. inyectados. Esto vale también para responses de error (500/400/etc.) — sin eso, el browser tapa el error real con un "CORS error" en consola.
Restricciones:
- Máximo un
cors(...)por ruta. Apilar dos da error. cors(...)y user-fn middlewares conviven sin problema:- En
fitz build,cors(...)se evalúa en build-time: el codegen emite unstatic __FITZ_CORS_*con los headers precomputados y un handler de preflight dedicado. Cero overhead por request.
Ejemplo completo: examples/guide/17b-middleware.fitz.
Con HTTP cerramos la Fase 4 y la mini-fase de Middleware + CORS
post-Fase 7. Tenés ahora todas las piezas para escribir APIs reales
en Fitz: rutas, JSON tipado, manejo de errores propagable,
configuración del server, status codes custom, query params,
middleware apilable y CORS configurable. El próximo capítulo cubre
la paridad con FastAPI en developer experience: documentación
de la API autogenerada (/openapi.json + UI Scalar en /docs).
Después, el cap 19 cubre la otra mitad de "HTTP nativo":
concurrencia con async fn y .await.
18. Docs automáticas¶
Fitz expone dos rutas más cuando hay handlers HTTP en el programa, sin que tengas que hacer nada:
| Ruta | Qué sirve |
|---|---|
GET /openapi.json |
Schema OpenAPI 3.1 autogenerado del programa |
GET /docs |
UI Scalar interactiva (carga el schema en el browser) |
Cualquier herramienta del ecosistema OpenAPI (Postman, Insomnia,
openapi-generator para SDKs en otros lenguajes, etc.) se enchufa
directo contra /openapi.json.
Cómo funciona¶
El runtime HTTP recorre los decoradores que ya escribiste (@get,
@post, @header, los tipos custom anotados como body, las
anotaciones de return type) y arma el schema en memoria al
arrancar el server. El subcomando fitz openapi archivo.fitz
escupe el mismo schema a stdout, útil para CI o snapshot testing
del contrato sin tener que levantar el server.
Mapping TypeExpr → JSON Schema¶
| Fitz | JSON Schema emitido |
|---|---|
Int |
{"type":"integer","format":"int64"} |
Float |
{"type":"number"} |
Str |
{"type":"string"} |
Bool |
{"type":"boolean"} |
T? |
schema de T + "nullable": true |
List<T> |
{"type":"array","items":<T>} |
Map<Str, V> |
{"type":"object","additionalProperties":<V>} |
Result<T> (en return) |
200 con schema de T + 500 con {error: string} |
User (nominal) |
{"$ref":"#/components/schemas/User"} |
Cada type Foo { ... } del programa entra a
components.schemas.Foo con sus campos como properties.
required incluye los campos sin default y no nullables; el
resto queda como opcional.
Headers como params del handler¶
@header(name="HTTP-Name") apilado antes del decorator de ruta
declara que un param del handler viene de un header HTTP. El
nombre del param Fitz se deriva por convención:
lowercase + - → _.
@header(name="Authorization")
@get("/private")
fn private(authorization: Str) -> Str => authorization
@header(name="X-Trace-Id")
@get("/traced")
fn traced(x_trace_id: Str?) -> Str => "ok"
Reglas:
- Tipos soportados:
Str(obligatorio) yStr?(opcional). Si declarás otro tipo, el evaluador rechaza con error claro. - Falta header obligatorio → respuesta
400con{"error":"header 'Foo': falta — es obligatorio"}. - Lookup case-insensitive en HTTP:
authorizationmatchea contra@header(name="Authorization"). - En el schema OpenAPI, los headers aparecen como
parametersconin: "header"yrequiredderivado del tipo.
Opt-out: @server(docs=false)¶
Default: docs habilitados. Si querés apagarlos (servidor más chico, schema no público, etc.):
Con docs=false, ni /openapi.json ni /docs se registran
(ambas devuelven 404). El opt-out funciona idéntico en fitz
run y fitz build.
Paridad fitz run ↔ fitz build¶
El schema generado es bit-a-bit idéntico entre fitz openapi
archivo.fitz, fitz run archivo.fitz (sirviendo /openapi.json)
y fitz build archivo.fitz (el binario nativo embebe el schema
como &'static str al compilar). Una sola fuente de verdad para
el contrato.
Si el usuario declara /openapi.json o /docs propio¶
@get("/openapi.json") fn miyo() -> ... se respeta — el
auto-register cede. Mismo comportamiento para /docs. Útil si
querés servir un schema custom o una UI distinta.
Ejemplo ejecutable¶
examples/guide/18-docs.fitz
muestra un CRUD chiquito con path params, query params, body
tipado, return Result<T> (status 200/500), header
obligatorio. Compila con fitz build end-to-end.
fitz run examples/guide/18-docs.fitz
# en otra terminal:
curl http://127.0.0.1:3000/openapi.json | head -20
open http://127.0.0.1:3000/docs # macOS — abrí la UI en el browser
Limitaciones conocidas¶
- Descripciones vacías:
info.descriptionypaths.*.*.descriptionno se llenan todavía. El lexer hoy descarta comentarios; doc-strings sobre handlers son deuda post-F17 (refactor invasivo del lexer/parser/AST). @headersolo aceptaStr/Str?: si querés un header numérico, parsealo adentro del handler.- Bundle Scalar offline: la UI
/docscarga el bundle JS desdecdn.jsdelivr.net. El browser cachea tras el primer load, pero hace falta red la primera vez. Embeber offline cuesta ~3.7 MB extra al binario; quedó como deuda menor.
Cerradas en la tanda Q (2026-05-14):
info.versionoverride:@server(api_version="X.Y.Z")lo refleja en el schema.- Aliases en
@header:@header(name="X-Auth", into="token")mapea explícito a un param Fitz. - Status codes custom en el schema: un handler con
return 404 { ... }produce ahora un entry"404"enresponses, con description vía reason phrase HTTP.
Status codes vía constantes top-level:
- El schema resuelve no solo literales (
return 404 { ... }) sino también identificadores que apuntan a unalet X = <Int>top-level del programa. Útil para nombrar los códigos:
let NOT_FOUND = 404
let UNAUTHORIZED = 401
type ApiErr { status: Int, message: Str }
@get("/users/{id}")
fn h(id: Int) -> Result<User, ApiErr> {
if (id == 0) {
return Err(ApiErr { status: NOT_FOUND, message: "no user" })
}
...
}
El schema OpenAPI incluye los responses 200 (return type),
401 y 404 (resueltos desde la tabla de consts). La sintaxis
return NOT_FOUND { ... } (Stmt::ReturnStatus directo con Ident
como status) también funciona y entra al schema.
Vars locales del handler o expresiones complejas (return
compute_status() { ... }) siguen invisibles para el schema —
caen al 500 default.
19. Async y concurrencia¶
Hasta acá las funciones de Fitz son sincrónicas: corren, devuelven,
fin. async fn agrega una segunda forma: la función devuelve un
valor pendiente (un Future<T>) que se "ejecuta" cuando otra
parte del código lo await-ea. Sirve para operaciones que toman
tiempo sin trabajo de CPU — esperar una respuesta HTTP, leer de
disco, dormir N milisegundos — sin bloquear todo el intérprete.
Fitz cumple la promesa de async nativo: async fn, Future<T> y
el operador .await están en el core del lenguaje, no en una lib.
El runtime tokio (de Rust) maneja el scheduling abajo; el usuario
solo ve la sintaxis.
async fn — declarar una función async¶
Una async fn se ve igual que una fn normal pero con el prefijo
async:
Por fuera, la firma de pausa es (Int) -> Future<Int>: llamarla
NO ejecuta el cuerpo, devuelve un Future que representa la
ejecución pendiente. Por dentro, los return n siguen retornando
Int puro — el async es transparente desde adentro del cuerpo.
.await — desempaqueta un Future¶
.await es postfix (después del valor) y encaja naturalmente
en method chains:
let n = pausa(100).await // espera 100ms, devuelve 100
let m = pausa(50).await + 1 // chain: 50 + 1 = 51
Sin .await, la llamada devuelve el Future<Int> "crudo" — útil
para guardarlo, pasarlo como argumento o componerlo. Con .await,
el future se ejecuta y obtenemos el Int interno.
Future<T> como tipo¶
Future<T> es un genérico built-in, igual que List<T> o
Result<T>:
Aparece naturalmente en el return type de cualquier async fn
(desde afuera) y se puede usar en anotaciones de variables,
parámetros y campos de tipo.
El builtin sleep(ms)¶
sleep(ms: Int) -> Future<Null> produce un future que pausa N
milisegundos cuando se await-ea. Es el primer "async primitive"
del lenguaje:
Dónde se permite .await¶
.await solo dispara la ejecución de un Future si está adentro
de un contexto async. Las reglas:
| Contexto | .await permitido |
|---|---|
Adentro de una async fn |
✅ sí |
| A nivel top-level del archivo | ✅ sí (el runtime tokio se arma al ejecutar) |
Adentro de una fn sync |
❌ error de tipo |
Adentro de fn(x) => ... (FnExpr / closure) |
❌ error de tipo |
El último caso es porque Fitz no soporta closures async
todavía (async fn(x) => ... no existe en la gramática). Si
necesitás un callback async, declarate una async fn con nombre
y pasala como valor.
Handlers HTTP async¶
Cualquier handler HTTP puede ser async fn. El runtime tokio
existente lo invoca con .await automático; el usuario no escribe
nada extra:
@server(3000)
fn main() => 0
@get("/lento")
async fn lento() -> Str {
let _ = sleep(500).await
return "después de medio segundo"
}
@get("/rapido")
fn rapido() -> Str => "ya"
Los handlers sync y async conviven libremente. axum los acepta
ambos. La diferencia se nota cuando el handler async hace I/O real
(en el futuro: fetch(url).await, db.query(...).await): un
endpoint que está esperando una respuesta externa cede CPU para
que el intérprete avance otras tareas adentro del mismo handler.
Paralelismo HTTP real¶
El runtime HTTP usa tokio en modo multi-thread: N workers
según los cores disponibles procesan handlers en simultáneo. Dos
requests al mismo handler — aún uno lento — corren en paralelo,
una por worker. Concurrencia real, no solo intercalada adentro
de un mismo handler.
Demostración con examples/guide/19b-paralelismo.fitz:
@server(3000)
fn main() => 0
@get("/lento")
async fn lento() -> Str {
let _ = sleep(1000).await
return "ok"
}
5 requests concurrentes vs 5 en serie:
fitz run examples/guide/19b-paralelismo.fitz &
# Paralelo: 5 requests al mismo tiempo
time seq 5 | xargs -P 5 -I _ curl -s http://127.0.0.1:3000/lento
# real 0m1.2s ← cada worker duerme 1s, todos en paralelo
# Serie: una atrás de otra
time for i in 1 2 3 4 5; do curl -s http://127.0.0.1:3000/lento; done
# real 0m5.3s ← suma de los sleeps
Pre-F17 ambos eran ~5s (el server estaba en current_thread).
Post-F17 los contenedores de Value y EnvRef migraron a
Arc<Mutex<>> (Send + Sync) y axum invoca el evaluator directo
sobre sus workers — sin bridge, sin serialización.
Para programas CLI con .await, esto no cambia nada: hay una
sola tarea a la vez, runtime current_thread es suficiente.
Ejemplos del capítulo¶
Dos ejemplos:
- examples/guide/19-async.fitz
— CLI con tres
async fnque se componen y se await-ean desde top-level. - examples/guide/19b-paralelismo.fitz
— server HTTP con un handler lento (
sleep(1000)) y uno rápido para medir paralelismo real con curl.
# CLI async
fitz run examples/guide/19-async.fitz
fitz build examples/guide/19-async.fitz
./examples/guide/19-async # Linux/macOS
.\examples\guide\19-async.exe # Windows
# Server con paralelismo
fitz run examples/guide/19b-paralelismo.fitz
fitz build examples/guide/19b-paralelismo.fitz
./examples/guide/19b-paralelismo &
time seq 5 | xargs -P 5 -I _ curl -s http://127.0.0.1:3000/lento
Salida esperada del cap 19 CLI:
Async cumple la promesa de "HTTP nativo" a nivel de ejecución:
podés escribir un handler que pausa, cede CPU, sigue. Y con N
workers tokio, varios pueden estar pausando a la vez. El próximo
capítulo cubre el otro gran salto: compilar el programa a un
binario nativo standalone con fitz build.
20. fitz build — compilar a binario nativo¶
Hasta acá usamos siempre fitz run: el intérprete lee el archivo,
lo lexea, parsea, chequea y ejecuta en proceso. Es rápido para
iterar y conserva toda la riqueza del lenguaje (lista heterogénea,
inferencia completa sin anotaciones, mutación implícita).
fitz build toma el mismo .fitz y produce un binario nativo
standalone que corre sin Fitz instalado. Es el modo "deployar":
más lento de compilar (segundos en vez de milisegundos), pero el
output es un ejecutable que podés copiar a otro servidor.
Cómo funciona¶
Hace, en orden:
- Lexer + parser: igual que
fitz run. - Type checker estático en modo strict (sin
--no-typecheck): los errores de tipo abortan el build acá. - Codegen: traduce el AST a un Cargo project completo
adentro de
target/fitz-build/<nombre>/. Estructura: cargo build --release: invoca Cargo, que llama a rustc. Si el programa tiene@get/@post/etc., elCargo.tomlincluyeaxum,tokio,serdeyserde_jsoncomo dependencias. Sin HTTP, queda minimalista.- Copia el binario producido (
target/release/hello) adyacente al.fitzoriginal. En Windows eshello.exe; en Linux/macOS,hello.
Inspeccionar el Rust generado es libre: el src/main.rs queda
ahí mientras no lo borres. Si rustc se queja, ver el código
generado suele desambiguar.
Mapping de tipos Fitz → Rust¶
Acá la traducción base, para que el código generado no te tome por sorpresa si lo abrís:
| Fitz | Rust |
|---|---|
Int |
i64 |
Float |
f64 |
Str |
String |
Bool |
bool |
Null |
() |
T? |
Option<T> |
List<T> |
Rc<RefCell<Vec<T>>> (referencia compartida) |
Map<K, V> |
Rc<RefCell<Vec<(K, V)>>> (orden de inserción preservado) |
Result<T> |
Result<T, String> (Err pinned a String — ver cap 14) |
type Foo { ... } |
struct FooData { ... } + type Foo = Rc<RefCell<FooData>>; |
Las instancias de tipos custom van detrás de Rc<RefCell<>> para
preservar la semántica del intérprete: mutar u.name = "x" a
través de un alias se ve en cualquier otra var que apunte a la
misma instancia.
Qué se soporta¶
| Feature | Soporte |
|---|---|
| Primitivos + operadores + interpolación | ✅ |
if / else / while / loop / for ... in |
✅ |
match con literales, ranges, Ok/Err, wildcards |
✅ |
| Tipos custom: instanciación, fields, defaults | ✅ |
| Listas y mapas (homogéneos), indexing, métodos | ✅ |
Result, ?, propagación de Err |
✅ |
Módulos: import foo / from foo import X |
✅ |
Funciones anónimas, closures, Fn(...) -> ... |
✅ |
HTTP: @get/@post/@put/@delete, @server |
✅ |
Body JSON deserializado contra type custom |
✅ |
| Serialización JSON automática de respuestas | ✅ |
Qué todavía no anda con fitz build¶
Cosas que sí corren con fitz run pero todavía no compilan:
- Funciones sin anotar params:
fn greet(name)yfn double(n) { return n * 2 }compilan sin anotaciones. El codegen infiere desde el primer call site (greet("Fitz")→name: Str;double(21)→n: Int) y deriva el return type de losreturn/tail-expr del body. Cubre Int/Float/Str/Bool/ Null/Bytes/Nullable/List/Map/Result + Nominal. Casos sin cubrir (raros): fns sin call site, Function/Range/Future como tipo de param. Cuando la inferencia falla, el codegen lo dice claro con sugerencia de anotar. - Heterogéneos con Functions/Tuples — F13 SPIKE + F13.A + F13.B
- F13.C + F13.D + F13.E cubren primitivos, Bytes, Nominales,
listas/mapas anidados con mix interno, HTTP body heterogéneo
(
body: List<Any>/Map<Str, Any>) y type-check dinámico via.as_int()/.as_str()/.type_name(). Lo que aún NO compila en heterogéneos: Functions y Tuples. Workaround:fitz runo desestructurar antes de meter en heterogéneo. - Wrap-style middleware —
fn mw(req, next: Fn() -> Response)con invocaciónnext()corre solo enfitz run. El codegen rechaza con msg claro citandofitz run. La implementación en codegen requiere emisión de cierre Rust con tipos async/Send/Sync recursivos no triviales (~2-3h dedicados) — última deuda residual real.
Features completas de
fitz build:let X = <expr>no literal a nivel top de un módulo (la RHS puede ser una expresión arbitraria que se evalúa lazy via accessor fn), imports transitivos, return type inference para handlers (fn create(u: User) { return ... }infiere-> Xdel body via TypeInfo del checker), división por cero literal (print(10 / 0)) panica condivisión por ceroen runtime — paridad bit-a-bit con el intérprete, comparar valores de tipos distintos (1 == "1",true != 0) compila y devuelvefalse/trueliteral sin error E0308, y heterogéneos completos enfitz build— listas/mapas con primitivos, Bytes, Nominales, listas/mapas anidados con mix interno, HTTP bodyList<Any>/Map<Str, Any>deserializando desde JSON, y type-check dinámico via.as_int()/.as_str()/.type_name()sobre items de heterogéneos. El caso 95%+ del lenguaje compila bit-a-bit confitz run. Solo falta: Functions y Tuples en heterogéneos. Verdocs/design-fitzvalue.mdpara el diseño.Value::Bytesen JSON se serializa como base64 string (estándar de facto).File.contentesBytes, habilita uploads binarios (imágenes, PDFs) end-to-end.statuscodes con expresiones simples (status: BASE + 4) se resuelven al schema OpenAPI vía const-eval.
Si te tropezás con algo de esta lista, el mensaje del codegen lo cita explícitamente. La salida tiene la forma:
✗ codegen: Error — <descripción> ...
(Fase 5b soporta un subset progresivo; los mensajes citan el sub-paso correspondiente.)
Lo que sí soporta: state HTTP compartido (cualquier
let users = [...]top-level que un handler referencia), paralelismo HTTP real entre requests con el binario compilado (runtime tokio multi-thread default), interop Python enfitz build(from python import Xproduce binarios nativos con PyO3 linkeado). Ver cap 19 sub-sección "Paralelismo HTTP real".
Ejemplo: programa CLI primitivo¶
let name = "Fitz"
let x = 10 + 5
print("Hola, {name}, x es {x}")
fn double(n: Int) -> Int => n * 2
print(double(x))
Ejemplo: server HTTP compilado¶
examples/guide/20-build.fitz
es un server HTTP simple que cubre endpoints estáticos, path params,
Result con Err → 500, y un POST con body deserializado a un type
custom:
@server(3000)
fn main() => 0
@get("/")
fn index() -> Str => "Fitz HTTP compilado"
@get("/double/{n}")
fn double(n: Int) -> Result<Int> {
if (n < 0) {
return Err("n debe ser >= 0")
}
return Ok(n * 2)
}
type Echo {
msg: Str
times: Int = 1
note: Str?
}
@post("/echo")
fn echo(body: Echo) -> Echo => body
$ fitz build examples/guide/20-build.fitz
✓ binario: examples/guide/20-build.exe
$ ./examples/guide/20-build.exe &
Fitz HTTP escuchando en http://127.0.0.1:3000
$ curl http://127.0.0.1:3000/
"Fitz HTTP compilado"
$ curl http://127.0.0.1:3000/double/21
42
$ curl http://127.0.0.1:3000/double/-1
{"error":"n debe ser >= 0"}
$ curl -X POST http://127.0.0.1:3000/echo \
-H "Content-Type: application/json" \
-d '{"msg":"hola"}'
{"msg":"hola","times":1,"note":null}
El binario es ~5 MB y arranca instantáneo. No necesita ni Fitz ni Rust instalados en la máquina destino: es un ejecutable nativo standalone.
Programas con interop Python (
from python import): elfitz builddefault produce un binario que linkea contra libpython, así que necesita Python instalado en el destino. El flagfitz build --bundle-pythonempaqueta CPython embebido adentro del binario — el resultado corre standalone sin Python en el destino. Tamaño +25-30 MB, pero el deploy se simplifica dramáticamente. Detalle en cap 21.11.
Cuándo usar fitz run y cuándo fitz build¶
- Iterando:
fitz run. Cambios en el.fitzse reflejan inmediato; sin paso de compilación. - Explorando features experimentales:
fitz run. Listas heterogéneas, error de runtime puro como división por cero — todo lo que está en "qué todavía no anda" sigue funcionando en el intérprete sin restricciones. - Producción / deploy:
fitz build. Un binario que se copia a un servidor y arranca, sin runtime de Fitz alrededor. - Distribuir un script CLI:
fitz build. El binario chico es más fácil de compartir que pedir a alguien que instale Fitz primero.
Cross-compilation¶
Como por debajo está rustc, cross-compilar es gratis vía
rustup target add <triple>. El subcomando fitz build todavía
no expone una flag --target, pero el flujo está abierto: en el
project generado podés correr cargo build --release --target
x86_64-unknown-linux-musl (o el target que precises) directo.
Si esto te interesa, abrí un issue.
Con fitz build cerramos Fase 5. El lenguaje tiene ahora todas
las piezas centrales: type checker estático, intérprete maduro,
y un compilador que genera binarios nativos para CLI y HTTP. El
último capítulo es el mapa hacia adelante.
21. Interop Python¶
Fitz puede llamar código Python desde sus programas. La motivación es práctica: Python tiene el ecosistema más grande del mundo (SQLAlchemy, numpy, pandas, httpx, FastAPI...). Pedirle al usuario de Fitz que reescriba todo de cero es irreal. La interop con Python es el puente para usar Fitz hoy, sin renunciar a las librerías que ya tenés en tu stack.
21.1 Setup¶
La interop con Python es opt-in: el binario fitz pre-compilado
de los releases NO linkea libpython (cero costo si no necesitás la
interop). Para activarla tenés que compilar fitz desde fuente con
la feature python:
git clone https://github.com/Thegreekman76/fitz.git
cd fitz
cargo build --release --features python
# El binario queda en target/release/fitz (copialo a tu PATH si querés)
O, si preferís no instalarlo global, podés usar cargo run directo
contra el repo durante desarrollo:
PyO3 linkea CPython 3.10+ al binario. Los programas Fitz que no
usan from python import siguen produciendo binarios libres como en
Fase 5b. Los ejemplos del resto del capítulo asumen que tenés un
fitz con la feature python activa en el PATH.
Política de venvs: el patrón estándar Python — activá tu venv
con source venv/bin/activate (o equivalente Windows) antes de
correr Fitz; CPython lee VIRTUAL_ENV automáticamente al bootear.
Sin venv, Python busca los paquetes en el site-packages global.
21.2 Sintaxis: from python import¶
Fitz reusa la sintaxis de imports que ya conocés del cap 16, con
un namespace virtual python reservado:
También aceptamos formas alternativas:
from python import math as m // alias
import python.os.path // import punteado, binding `path`
from python import os.path as p // alias + path punteado
El último segmento del path (o el alias) es el binding visible en el scope Fitz.
21.3 Constantes y atributos¶
math.pi, os.name, etc. — el field access funciona igual que
sobre tipos Fitz, pero del otro lado hay un objeto Python:
from python import math
print("pi = {math.pi}") // pi = 3.141592653589793
print("e = {math.e}") // e = 2.718281828459045
print("nombre = {math.__name__}") // nombre = math
El intérprete coerciona automáticamente los primitivos Python
(int → Int, float → Float, str → Str, bool → Bool,
None → Null). Cualquier otro tipo (función, clase, instancia,
submódulo) queda como PyObject opaco — podés pasarlo a otra
fn Python o hacer field access más adentro, pero Fitz no sabe qué
hay adentro.
21.4 Llamadas a funciones Python¶
Las llamadas usan la sintaxis de Fitz pero con un detalle clave:
toda llamada Python devuelve un Result<T> automáticamente.
from python import math
let raw = math.sqrt(16.0)
match raw {
Ok(v) => print("sqrt(16) = {v}"), // sqrt(16) = 4.0
Err(e) => print("error: {e}"),
}
Si la función Python lanza una excepción (ValueError,
TypeError, etc.), Fitz la captura y la convierte en
Err(Str("<ClassName>: <message>")):
let raw = math.sqrt(-1.0)
match raw {
Ok(_) => print("(no debería)"),
Err(e) => print("caught: {e}"), // caught: ValueError: math domain error
}
Esto preserva la decisión de diseño "sin excepciones" de Fitz: el
usuario es forzado a manejar la falla con match o ?, igual
que con find/get/json.loads nativos. El programa Fitz no
aborta por excepciones Python.
21.5 Propagación con ?¶
Adentro de una fn que retorna Result<T>, el operador ? propaga
errores Python sin glue manual:
from python import math
fn root_safe(x: Float) -> Result<Float> {
let v: Float = math.sqrt(x)?
return Ok(v)
}
match root_safe(25.0) {
Ok(r) => print("r = {r}"), // r = 5.0
Err(e) => print("err: {e}"),
}
Notá la anotación destino let v: Float = ...: como
math.sqrt(x)? desempaca a PyAny, la anotación dispara una
coerción automática de Python float a Float Fitz. Sin la
anotación, v queda como PyAny opaco.
21.6 Marshaling de tipos compuestos¶
Listas, mapas e instancias Fitz cruzan a Python como list, dict
y dict (por field name), respectivamente:
from python import json
let xs: List<Int> = [1, 2, 3]
match json.dumps(xs) {
Ok(s) => print(s), // [1, 2, 3]
Err(_) => print("err"),
}
type User { id: Int, name: Str }
let u = User { id: 1, name: "Ada" }
match json.dumps(u) {
Ok(s) => print(s), // {"id": 1, "name": "Ada"}
Err(_) => print("err"),
}
En sentido contrario, Python list y dict cruzan a Fitz como
List y Map opacos (sin tipo concreto del lado Fitz). El próximo
capítulo cubre cómo recuperar el tipo Fitz con anotaciones.
21.7 Recuperando tipos Fitz desde Python¶
Cuando una fn Python te devuelve un dict que querés tratar como
una instancia Fitz, anotá el binding destino y el runtime hace
la coerción automática:
from python import json
type User {
id: Int,
name: Str,
email: Str? = null,
}
fn parse_user(s: Str) -> Result<User> {
let row: User = json.loads(s)?
return Ok(row)
}
match parse_user("{\"id\": 1, \"name\": \"Ada\"}") {
Ok(u) => print("User: {u.name}"), // User: Ada
Err(e) => print("err: {e}"),
}
El runtime itera los fields declarados:
- Si el
dicttiene el field → lo usa. - Si no, aplica el default (si hay).
- Si no hay default pero el field es nullable →
null. - Si no hay default ni es nullable → error claro.
Campos extras del dict se ignoran silenciosamente (Python suele
devolver más data de la necesaria; SQLAlchemy es típico ahí).
21.8 fitz py-types — auto-mapeo de modelos SQLAlchemy¶
Si tu proyecto Python usa SQLAlchemy, podés generar los type
Fitz correspondientes con un comando:
El comando introspecciona las clases con __table__.columns y emite:
// Generado por fitz py-types desde models.py
type User {
id: Int,
name: Str,
email: Str? = null,
created_at: Str,
}
Mapeo: Integer/BigInteger → Int, Float/Numeric → Float,
String/Text → Str, Boolean → Bool, DateTime/Date → Str
(ISO 8601), nullable=True → ?, default literal inline, callable
ignorado. Tipos desconocidos quedan como Any con un comentario
para refinar a mano.
Después de generar, podés usar from models import User, Order
en tus archivos Fitz y los tipos están listos para combinar con
el patrón let row: User = py_call(...)? del cap 21.7.
21.8b Stubs .pyi — dos modos: manual (fitz py-stubs) y auto-pickup¶
Para librerías Python con stubs .pyi (typeshed, paquetes
types-<package> del PyPI, stubs adjuntos al proyecto, o
escritos a mano), Fitz ofrece dos modalidades complementarias.
Útil para integrar libs comunes (requests, numpy, etc.) sin
escribir type Fitz a mano y con tipado estático real en lugar
de PyAny opaco.
Modo 1 — Manual (fitz py-stubs): comando que parsea el
.pyi y emite un archivo .fitz commiteable con los type
correspondientes. Útil cuando querés tener los tipos versionados
explícitamente en tu repo y poder editarlos a mano.
Modo 2 — Auto-pickup: si el archivo .fitz tiene
from python import foo y existe foo.pyi adyacente (mismo
directorio), el loader del checker lo detecta
automáticamente, parsea, y registra los tipos al TypeEnv sin
intervención. El user solo tiene que dropear el .pyi al lado
del .fitz raíz.
# main.fitz — sin imports extra, sin sub-comandos manuales
from python import requests
fn fetch_user(id: Int) -> Result<User> {
return Ok(requests.get_user(id)?)
# `requests.get_user` resuelve a `fn(Int) -> Result<User>`
# leído del `requests.pyi` adyacente.
# `User` resuelve a un nominal real con sus fields del stub.
}
Beneficios sobre el modo manual:
- Cero archivos intermedios. No genera ni commitea un .fitz.
El .pyi es la única fuente de verdad.
- Lookup automático. El loader busca <base_dir>/<name>.pyi
para cada from python import <name>. base_dir = dir del
.fitz que arranca fitz run/fitz build/fitz check.
- Field access tipado. Después del auto-pickup,
requests.get_user(42) tipa estáticamente como
Result<User> — el ? desempaca a User y el match exhaustivo
funciona. Pre-fix tipaba como Result<Any> gradual.
- Silent fallback. Si el .pyi no existe o está malformado,
el binding vuelve a PyAny opaco (comportamiento previo).
Nunca rompe el build por un stub problemático — emite warning
a stderr y sigue.
- Política "el .fitz gana". Si el programa declara
type User { ... } Y el stub también declara class User,
los fields del .fitz ganan (el stub se ignora para ese
nombre). Permite refinar stubs upstream sin perder control.
Lookup local-only por diseño: el loader solo mira el dir
adyacente al .fitz raíz. No recorre PYTHONPATH ni
site-packages. Si querés tipar libs de PyPI, copiá el .pyi
al repo (o usá pip show -f <package> | grep .pyi para
encontrarlos en el venv). Paridad reproducible entre máquinas;
cero magia ambiente.
Sub-set cubierto (compartido por ambos modos):
class Foo: ...con fields anotados →type Foo { ... }(manual) o nominal en TypeEnv (auto).- Tipos primitivos:
int,float,str,bool,bytes,None→Int,Float,Str,Bool,Bytes,Null. - Generics:
list[T],dict[K, V]→List<T>,Map<K, V>. - Nullable:
Optional[T]y PEP 604T | None→T?. - Forward refs:
"Foo"(string) →Foonominal. def name(args) -> ret(solo modo auto, 8-pyi.C): se registra como callable tipado adentro del módulo. El callfoo.name(args)tipa estáticamente con la firma del stub. Auto-wrap: el ret se envuelve enResult<ret, Str>reflejando el modelo runtime (toda call Python wrappea en Result automático, ver §21.5).var name: typetop-level (solo modo auto, 8-pyi.C): se registra como field directo del módulo.foo.vartipa con el tipo declarado, sin wrap.
Restricciones (deuda menor compartida):
- Métodos de class se ignoran. Hoy solo fields. Métodos custom de tipos importados quedan PyAny.
- Sin sync upstream automático: regenerar el
.fitzmanual o re-dropear el.pyiactualizado si la lib cambia. - Built-ins de Fitz colisionan (
Response,Request,File, etc.). Si el stub tiene un class con esos nombres, el loader auto los skipea silenciosamente; confitz py-stubs, renombrar manualmente en el.fitzgenerado (ej.Response→ApiResponse).
¿Cuándo usar cada modo?
| Caso | Modo |
|---|---|
| Lib estable, quiero versionar los tipos | fitz py-stubs (manual) |
| Lib en desarrollo, refresh frecuente | Auto-pickup |
| Querés EDITAR los tipos para refinarlos | fitz py-stubs |
| Stub generado por terceros, leés-only | Auto-pickup |
Quiero type Fitz para usar también desde otros .fitz |
fitz py-stubs |
| Solo querés tipado dentro del mismo archivo | Auto-pickup |
Los dos modos son compatibles: podés usar fitz py-stubs para
generar un .fitz versionado de requests.pyi, e independiente
tener un local_helpers.pyi adyacente al main.fitz para tipado
de helpers Python rápidos.
Ejemplos runnable:
- Modo manual: examples/guide/21b-pyi-stubs.fitz.
- Modo auto-pickup: examples/guide/21c-pyi-autopickup/ (programa Fitz + .pyi adyacente).
21.9 Async — await sobre corutinas Python¶
Cuando una fn Python es async def (devuelve una corutina), el
.await Fitz la ejecuta vía el bridge tokio ↔ asyncio:
from python import asyncio
async fn esperita() -> Result<Str> {
let _ = asyncio.sleep(0.1)?.await
return Ok("done")
}
match esperita().await {
Ok(v) => print("got = {v}"), // got = done
Err(e) => print("err: {e}"),
}
El patrón canónico Fitz es <py_call>?.await. El ? desempaca
el Result del call (el wrap automático del cap 21.4); el .await
ejecuta la corutina. Excepciones asyncio aparecen como Err,
igual que con calls sync.
Binding intermedio (paridad bit-a-bit fitz run ↔ fitz
build): también funciona separar el call y el await en dos
statements, lo cual es útil cuando hay lógica entre los dos
(logging, agregar metadata al future, decidir si esperarlo o no):
from python import asyncio
async fn run() -> Result<Null> {
let fut = asyncio.sleep(0.5)? // fut: PyAny — coroutine ya construido
print("sleep arrancado, esperando...")
let _ = fut.await // await sobre la var
return Ok(null)
}
El codegen detecta inner_ty == PyAny en el .await y despacha
al helper dedicado __fitz_py_await_obj (paralelo al
__fitz_py_invoke_await del patrón inmediato).
Implementación: el bridge usa tokio::task::spawn_blocking +
asyncio.run_until_complete() adentro del worker. Funcional para
APIs DB-bound (queries SQLAlchemy/asyncpg cortas). El GIL serializa
las corutinas — para hot paths CPU-bound, mejor reescribir en
Fitz nativo.
21.10 fitz build con interop Python¶
fitz build también compila programas con from python import.
El binario resultante linkea pyo3 con abi3-py310 + auto-initialize
y asume Python instalado en la máquina destino (igual que el
binario fitz mismo necesita CPython al boot).
Bit-a-bit con fitz run para los patrones cubiertos. Coerción
soportada por fitz build:
PyAny → primitivo(Int/Float/Str/Bool):let pi: Float = math.pi.PyAny → List<primitivo>:let xs: List<Int> = json. loads(raw)?.PyAny → Instance<T>yPyAny → List<Instance<T>>: funciona también cuandoTestá definido en otro módulo Fitz. Main emite los helpers__fitz_py_to_instance_<T>para tipos custom de módulos transitivos; cada módulo los referencia concrate::__fitz_py_*.
Lo que falta vs fitz run (deuda residual menor):
- Coerción Python
dict→ FitzMap<K,V>con K/V no primitivos (queda como PyObject opaco; raro en práctica). - Propagación de expected type adentro de
Ok(...): una fn que retornaResult<Str>y hacelet s = py_call()?; return Ok(s)necesita binding intermedio anotadolet s: Str = py_call()?. Refinable en una pasada futura del checker.
from python import en módulos transitivos: soportado.
Cada .fitz puede declarar sus propios imports Python sin
obligar al main a participar. El codegen reusa los helpers del
preludio Python del crate root via use crate::__fitz_py_* y
emite statics + getters locales por módulo. Útil para librerías
Fitz que delegan a Python (sqlalchemy/numpy/redis-py) y quieren
encapsular el detalle. Ver
examples/python-interop-modular.fitz
+ examples/python_math_utils.fitz
para el patrón canónico.
Para producir un binario que NO requiera Python en el destino
(realmente standalone), usá --bundle-python — sección siguiente.
21.11 fitz build --bundle-python — binario standalone¶
fitz build --bundle-python produce un binario que internamente
lleva embebida una distribución completa de CPython 3.14.5 (vía
python-build-standalone,
el proyecto que Astral usa para uv). El binario resultante NO
requiere Python instalado en el destino — corre en cualquier
máquina del triple soportado, en frío:
Cuándo usarlo:
- Deploys a containers minimalistas (Distroless, scratch + glibc).
- Distribución a usuarios finales que no quieren
pip install. - Demos / portable scripts que viajan en un solo archivo.
Cuándo NO usarlo:
- Si tu deploy ya garantiza Python instalado (
FROM python:3.X-slim, máquinas con Python pre-configurado): el bundle suma ~30-45 MB sin beneficio. - Si tu programa NO usa
from python import(el flag aborta con error claro — el binario default ya es standalone).
Tamaños observados (CPython 3.14.5 install_only_stripped):
| Triple | Binario final | Extract dir (TMPDIR) |
|---|---|---|
x86_64-pc-windows-msvc |
~22 MB | ~61 MB |
x86_64-unknown-linux-gnu |
~35 MB | ~75 MB |
x86_64-apple-darwin |
~24 MB | ~62 MB |
aarch64-apple-darwin |
~24 MB | ~62 MB |
aarch64-unknown-linux-gnu |
~28 MB | ~70 MB |
Timing observado (Windows 11 SSD, ejemplo de math.pi + math.sqrt):
- Cold first run (cache TMP vacío): ~3-5s (tar -xzf de 60 MB + boot CPython adentro del real binary).
- Warm subsequent runs: ~50-100ms (cache hit; sentinel
.extractedpresente, solo se hace exec del real binary).
Cómo funciona internamente (modelo Datasette Desktop):
El output user-facing es UN solo binario, pero internamente lleva
tres piezas embebidas vía include_bytes!:
- Tarball PBS (CPython 3.14.5 install_only_stripped del triple destino, ~21 MB Windows / ~34 MB Linux x64).
- Real binary (transpile estándar del programa Fitz con
feature
pythonactivada, ~180 KB Windows). - Launcher Rust standalone (sin pyo3, ~200 KB).
El launcher es la entry point del usuario. En primer run:
- Calcula hash FNV-1a del tarball (16 chars hex).
- Si
$TMPDIR/fitz-py-<hash>/.extractedno existe → extrae tarball contar -xzf(bsdtar en Win11/macOS, GNU tar en Linux). - Setea
PYTHONHOME=<extract-dir>/python+ ENV vars de búsqueda de lib según OS: - Linux:
LD_LIBRARY_PATHprepend con<extract-dir>/python/lib. - macOS:
DYLD_FALLBACK_LIBRARY_PATHprepend. - Windows:
PATHprepend con<extract-dir>/python(donde vivepython3.dll). execdel real binary en Unix (proceso reemplazado, signals forwarded transparente);spawn + wait + propagate exit codeen Windows.
El real binary arranca con el environment correcto, dlopen
encuentra libpython, PyO3 boot lazy en el primer Python::attach,
y todo el resto funciona igual que en fitz run/fitz build
normal — paridad bit-a-bit.
Constraint arquitectural permanente (Linux/macOS):
En Linux y macOS, el real binary linkea contra
libpython3.X.so.1.0 específica del builder. No es un bug
cerrable — es una limitación intrínseca de PyO3 + Linux/glibc:
en esas plataformas NO existe equivalente al python3.dll
stable-ABI shim de Windows. El archivo
/usr/local/lib/libpython3.so que traen las distros Python es un
dummy de 13 KB sin símbolos del API Python; los símbolos abi3
viven solo en la libpython versioned. Verificado empíricamente
2026-05-24 con experimento Docker cross-version; ver
docs/deudas_lenguaje.md sección R.bug-pyo3-abi3-portable-link.
Consecuencia para --bundle-python: el bundle PBS trae libpython
3.14.5, entonces el builder necesita tener Python 3.14.x
disponible al momento de cargo build para que las versiones
coincidan en runtime. Constraint permanente, no temporal.
En Windows el problema no existe — el real binary linkea contra
python3.dll (stable ABI shim real) y cualquier versión 3.10+
del bundle satisface la dependencia.
Setup del builder en Linux/macOS:
# Ubuntu/Debian:
apt-get install python3.14 python3.14-dev
# macOS con Homebrew:
brew install python@3.14
# Después:
fitz build --bundle-python mi_app.fitz
Cache local del tarball PBS:
El tarball se descarga UNA vez a ~/.fitz/cache/pbs/ (override
con FITZ_CACHE_DIR) y se reusa entre builds. Tamaño ~21 MB
Windows / ~34 MB Linux x64. Descarga vía curl (asumimos
disponible en Win11/macOS/Linux moderno).
Pendientes (deuda residual de 8.b, NO bloquean uso real):
- Bundling Linux/macOS necesita validación manual end-to-end (smoke local hecho en Windows; Linux/macOS lo confirma el primer usuario que lo pruebe ahí).
- Bundle más chico vía stdlib stripping de módulos no usados (~30% más reducción posible para programas con interop simple).
- Hash criptográfico (SHA256) en lugar de FNV-1a para defender contra cambios silenciosos del PBS upstream.
Importante — ¿necesito Python local para usar este flag? El binario en el destino corre sin Python instalado (esa es la promesa). Pero el dev que BUILDEA con
fitz build --bundle-pythonsí necesita Python al build time: en Windows cualquier 3.10+, en Linux/macOS específicamente 3.14.x (constraint arquitectural permanente de PyO3 + Linux/glibc; verdocs/deudas_lenguaje.md). Tabla completa de matices al final del cap 21.12, después del flag hermano--bundle-pip.
21.12 fitz build --bundle-pip — empaquetar paquetes pip¶
El flag --bundle-python que vimos arriba empaqueta CPython base
+ stdlib. Para programas Fitz que solo usan stdlib (math, json,
os, datetime, etc.) eso alcanza. Pero el caso de uso real más
común — SQLAlchemy + Postgres, requests, numpy/pandas, FastAPI
client, etc. — requiere paquetes pip además de la stdlib.
fitz build --bundle-pip <paquete> resuelve eso. Empaqueta los
paquetes pip junto al CPython base, todo adentro del mismo
binario standalone. Implica --bundle-python automáticamente.
# Un paquete
fitz build --bundle-pip requests mi_app.fitz
# Múltiples (flag repetible)
fitz build \
--bundle-pip sqlalchemy \
--bundle-pip psycopg2-binary \
--bundle-pip "redis==5.0.0" \
mi_app.fitz
# Desde un requirements.txt (cosecha de 8.c, equivalente al
# anterior pero leyendo del archivo):
fitz build --bundle-pip-requirements requirements.txt mi_app.fitz
# Combinable: pip acumula los positionals + el contenido del file
fitz build \
--bundle-pip-requirements requirements.txt \
--bundle-pip "psycopg2-binary==2.9.10" \
mi_app.fitz
# El binario corre sin Python instalado, sin `pip install`,
# sin requirements.txt en el destino.
./mi_app
--bundle-pip-requirements <FILE>es repetible (acepta varios archivos:requirements.txt+requirements-prod.txt). El archivo se pasa directo apip install -r <file>, así que toda la sintaxis nativa de pip funciona sin cambios: comentarios con#, includes-r other.txt, version pins,--hash, índices alternos con--index-url, etc. Validación temprana: si el archivo no existe o no se puede leer,fitz buildaborta antes de tocar PBS o pip.
Cómo funciona internamente:
- Build time: descarga el tarball PBS al cache (igual que
--bundle-python). - Extrae PBS a
target/fitz-build/<bin>_pbs_extract/para tener un Python ejecutable adentro del proyecto. - Ejecuta
<pbs>/python -m pip install --target <dir> <paquetes>adentro detarget/fitz-build/<bin>_pip_packages/. El--targetinstala los paquetes sin tocar el sistema ni el venv del usuario. - Empaca el directorio resultante en
target/fitz-build/<bin>_pip_packages.tar.gz. - El launcher embebe dos tarballs vía
include_bytes!: el PBS base (compartido entre proyectos) + el pip packages (específico de este proyecto). - En primer run del binario, el launcher extrae ambos al
$TMPDIR/fitz-py-<hash>/(el hash incluye los bytes del pip tarball, así que proyectos con distintos paquetes tienen distintos extract dirs — sin colisión).
Tamaños y timing observados (Windows 11 SSD):
| Bundle | Tamaño bin | Cold first run | Warm runs |
|---|---|---|---|
Solo --bundle-python (stdlib) |
~22 MB | ~3-5s | ~50-100ms |
+ --bundle-pip requests |
~23 MB | ~5-7s | ~50-100ms |
+ --bundle-pip sqlalchemy + psycopg2-binary |
~50 MB | ~8-12s | ~50-100ms |
Programa de ejemplo runnable:
// examples/python-interop-8.c.fitz
from python import requests
print("Módulo requests cargado desde el bundle pip:")
print(requests.__name__)
print(requests.__version__)
$ fitz build --bundle-pip requests examples/python-interop-8.c.fitz
→ compilando real binary…
→ asegurando PBS tarball (cpython 3.14.5 / x86_64-pc-windows-msvc)…
→ extrayendo PBS al cache local para correr pip (1 paquete(s))…
→ pip install --target (1 paquete(s))…
→ empacando pip_packages.tar.gz…
→ compilando launcher…
✓ binario standalone (CPython 3.14.5 + 1 pip pkg(s) embebidos):
python-interop-8.c.exe (22.9 MB)
# En cualquier máquina del triple destino, SIN Python instalado:
$ ./python-interop-8.c.exe
Módulo requests cargado desde el bundle pip:
requests
2.34.2
Limitaciones y caveats:
- Paquetes con C extensions nativas (psycopg2-binary, numpy,
Pillow): funcionan, pero el
.whlque pip elige al build time es específico del triple del builder. Si buildeás enx86_64-pc-windows-msvcy querés correr enx86_64-unknown-linux-gnu, las extensions no son portables. Workaround: buildea adentro de Docker con la imagen del target (la imagen oficialghcr.io/thegreekman76/fitz:latest-pythones Linux x64). - Pure-Python packages (requests, sqlalchemy, click, jinja2):
son cross-platform — el mismo bundle corre en Linux,
macOS y Windows porque son
.pypuros sin C extensions. pip installrequiere red al BUILD time. Una vez que el binario está hecho, el deploy no requiere red.- Versions pin: usá la sintaxis nativa de pip:
--bundle-pip "sqlalchemy==2.0.0"o--bundle-pip "redis>=5.0,<6.0". - Builder requiere
tar: igual que--bundle-python. En Windows 11 viene nativo (C:\Windows\system32\tar.exe). - Constraint heredado de
--bundle-python: en Linux/macOS, el builder debe tener Python 3.14.x para quepip installuse el mismo intérprete que el bundle. En Windows el shimpython3.dllpermite cualquier 3.10+.
Casos de uso reales del feature:
- Distribución a usuarios finales de scripts CLI que usan
bibliotecas Python ricas (yt-dlp, beautifulsoup4, etc.): un
solo binario en lugar de
pip install+ lista de dependencies. - Deploys a containers minimalistas:
FROM scratchoFROM gcr.io/distroless/cc-debian12en lugar deFROM python:3.X-slim. Imagen ~150 MB → ~80-100 MB. - Tooling interno de equipos donde no querés que cada user
configure su venv ni instale
requirements.txt.
Para boilerplates ⅚ (api-postgres-python, api-fullstack-postgres) este es exactamente el feature que faltaba. La sección "Roadmap del boilerplate" en cada uno tiene el plan de simplificación cuando este flag llegue.
¿Entonces ya no necesito Python ni pip ni venv?¶
Pregunta razonable y la respuesta tiene matices. Depende de QUIÉN y CUÁNDO:
| Contexto | Python local | pip install local |
venv |
|---|---|---|---|
Correr el binario --bundle-pip (deploy) |
❌ No | ❌ No | ❌ No |
Buildear con fitz build --bundle-pip |
✅ Sí (3.14.x en Linux/macOS) | ❌ No | ❌ No |
Iterar con fitz run --features python |
✅ Sí | ✅ Sí | ⚠️ Opcional |
Para el usuario final que corre el binario en producción: cero Python, cero pip, cero venv. Copia el binario, lo ejecuta, funciona. Es la promesa del binario standalone llevada hasta el final del stack — sin runtime externo, sin packages adicionales que instalar.
Para el developer que buildea con fitz build --bundle-pip:
casi, pero no del todo. El build necesita:
- Un Python instalado en la máquina (Windows: cualquier 3.10+
por el shim
python3.dll; Linux/macOS: 3.14.x específicamente por el constraint arquitectural permanente de PyO3 + Linux/glibc). Esto es estructural, no temporal. - NO necesita
pip install <paquete>local —fitz buildlos baja al cache y los empaqueta solo. - NO necesita venv activado.
- Sí necesita
tarycurlen elPATH(vienen nativos en Win11/macOS/Linux moderno).
Para el developer iterando con fitz run --features python
(intérprete, NO compilador): no cambia — sigue necesitando lo
de siempre. Python instalado, paquetes pip instalados
localmente, venv si querés aislamiento. fitz run es el modo
de iteración rápida; no bundlea nada, igual que python
script.py necesita su environment.
Conclusión práctica: el feature destraba un escenario
específico — el binario en el destino es standalone. El
flow de desarrollo (escribir código, iterar, debuggear) sigue
necesitando Python local porque fitz run no compila.
21.13 Ejemplo CRUD ejecutable¶
examples/guide/21-python-crud/ arma un CRUD completo:
models.py— modelo SQLAlchemy (User) sobre SQLite.db.py— helpers DB (init, add, list, get) que devuelven dicts/lists nativos Python (sin instancias del modelo SQLAlchemy) para que el marshaling a Fitz sea directo.models.fitz— output defitz py-types models.py.app.fitz— handlers HTTP@get/@postque insertan y listan usuarios via los helpers dedb.py.
Setup (una vez):
Correr (PYTHONPATH apunta a la carpeta del ejemplo para que
Python encuentre db.py y models.py):
# Linux/macOS:
PYTHONPATH=examples/guide/21-python-crud \
fitz run examples/guide/21-python-crud/app.fitz
# Windows PowerShell:
$env:PYTHONPATH = "examples\guide\21-python-crud"
fitz run examples/guide/21-python-crud/app.fitz
El server arranca en 127.0.0.1:3000. Probalo con curl:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Ada", "email": "ada@example.com"}'
curl http://localhost:3000/users
El ejemplo demuestra el flujo completo: bindings Python → fns helper Fitz que llaman SQLAlchemy → handlers HTTP que serializan las filas a JSON.
21.14 Limitaciones — lo que NO anda¶
Para mantener la honestidad del lenguaje:
- GIL de Python: las corutinas Python compiten por el GIL adentro del bridge. Para hot paths concurrentes, reescribir en Fitz nativo (sin interop) es mejor.
- Numpy/pandas con C extensions: funcionan si están instalados en el venv. Bundlearlos standalone es notoriamente difícil (deuda del ecosistema Python, no de Fitz).
- Herencia desde clases Python: no soportada. Un
typeFitz no puede heredar de una clase Python (los modelos de objetos son distintos — Python tiene MRO, Fitz no tiene clases). La composición sí: untypepuede tener un campo opaco que envuelve un objeto Python. asyncio.gathercon futures Fitz: el marshaling Future Fitz → corutina Python no está soportado. Workaround: definir elgatheradentro de un helper Python que toma corutinas.
Si necesitás algo de la lista de arriba y no aparece como deuda abierta en el roadmap, abrí un issue.
Cierre Parte 9. Interop Python te permite usar el ecosistema más grande del mundo desde Fitz sin renunciar a tipos estáticos ni a HTTP nativo. Con eso, Fitz es usable hoy para proyectos que ya viven en Python — el stack nativo (ORM, driver Postgres) llega como fase posterior. El último capítulo es el mapa hacia adelante.
22. Soporte para editores¶
Hasta acá la guía cubrió el lenguaje y sus herramientas de línea de comando. Pero la experiencia diaria de escribir código pasa por el editor: errores subrayados al tipear, autocompletar, navegación. Fitz tiene su propio Language Server Protocol (LSP) y una extensión VSCode que lo aprovecha.
Qué da hoy¶
- Syntax highlighting sobre archivos
.fitz(grammar TextMate embebida en la extensión). Colorea keywords (let,fn,if,match,async/await,not/and/or...), tipos built-in (Int,Float,Str,Bool,List,Map,Result,Future...), tipos nominales (User,Order...), strings con interpolación — incluyendo multilínea"""..."""(cap 5) —, el{name}resaltado distinto del resto, números, decoradores (@get,@server,@middleware...), comentarios (//y/* */), constantes (true/false/null/Ok/Err), built-ins (print/len/sleep/cors/assert/assert_eq/assert_ne/assert_throws), labels de loops ('outer), operadores compuestos (+=/-=/...) y rangos inclusivos (..=). - Diagnostics en vivo — los errores del lexer, parser y type
checker aparecen subrayados en rojo al tipear, con el mismo
mensaje + sugerencia que
fitz checkmuestra en la terminal. Pipeline: tokenize → parser tolerante a buffer en construcción → type checker. SeverityERROR, source"fitz"(visible en el Problems panel de VSCode). - Hover con tipos — pasás el mouse sobre una variable o
expresión y aparece su tipo en un tooltip (renderizado como
bloque de código Fitz, con syntax highlighting). El símbolo bajo
el cursor se highlightea visualmente (el Range del hover
cubre exactamente el ident).
Funciona sobre literales (
42→Int), identificadores en uso (nombre→Str), expresiones compuestas (xs.map(...)→List<U>), tipos nominales (u→User). Heurística pragmática: el último Expr iniciado antes del cursor en la misma línea. - Go-to-definition — F12 (o Ctrl+Click) sobre el uso de una
variable, función o tipo te lleva a la línea de su declaración.
Funciona sobre variables locales (
let x = ...), funciones top-level (fn foo(...)), tipos custom (type User { ... }), parámetros de fn, variables delfor ... in, bindings dematch(Ok(x),Err(e),Ident pat), e imports (incluido cross-module: F12 sobreUsercuando el import esfrom foo import Usersalta a la línea deltype User { ... }adentro defoo.fitz, no al stmt de import local). Los built-ins (print/len/sleep/cors) no resuelven (no hay archivo donde saltar). - Autocomplete contextual — al tipear, VSCode te muestra una lista de sugerencias según el contexto:
- Tras
.(caso after-dot): si el receiver es un tipo custom (u: User), aparecen sus fields y sus métodos custom (firmafn(T1, T2) -> Retoasync fn(...) -> Reten el detail). Si esList<T>, sus 9 métodos built-in (push/pop/map/filter/find/len/sort/reverse/contains). Si esMap<K, V>, sus 5 (get/has/keys/values/len). Si esStr, sus 10 (upper/lower/len/contains/starts_with/ends_with/split/trim/replace/repeat). Si es un tuple ((Int, Str, Bool)), aparecen los índices0/1/2como campos con el tipo de cada elemento. Para otros tipos (Any,PyAny, primitivos) la lista queda vacía. - En cualquier otra posición (caso scope-level): aparecen las
variables y funciones top-level del archivo, los tipos custom
declarados, los símbolos importados, los builtins (
print/len/sleep/cors), los tipos built-in (Int/Float/Str/Bool/List/Map/...), y los keywords del lenguaje (let/fn/if/match/...). Vars locales y params del scope contenedor también aparecen (scope-aware): si estás tipeando adentro del body defn greet(name: Str) { ... },nameaparece en la lista;let local = 42previo también;for item in xs { ... }agregaitemdentro del body.
Lo que viene¶
El MVP del LSP está completo — las 5 sub-fases visibles (9.x.1 → 9.x.5) están cerradas. Lo que sigue son refinamientos opcionales post-MVP (sin sub-fase asignada todavía):
- Publicación real al VSCode Marketplace — la extensión está
lista (
.vsixper-plataforma generable), pero la publicación requiere acciones del autor (cuenta de publisher, decisión sobre hacer el repo público). Ver "Publicar al Marketplace" al final de este capítulo. - CI multi-platform — GitHub Actions workflow que genera los
.vsixde las 6 plataformas automáticamente en cada release. - Features LSP refinadas: rename, refactoring, semantic highlighting, inlay hints, hover con docstrings, etc. Cuando aparezca demanda real.
Cómo lo instalo¶
Hay tres modalidades, ordenadas de menor a mayor esfuerzo:
A) Bajar el .vsix de releases (recomendado para uso normal)¶
Cada tag vX.Y.Z publica los .vsix per-plataforma como assets
del release en GitHub. Sin compilar nada local.
- Andá a releases
y bajá el
.vsixcorrespondiente a tu sistema:
| Plataforma | Archivo |
|---|---|
| Windows x64 | fitz-lang-win32-x64.vsix |
| Linux x64 | fitz-lang-linux-x64.vsix |
| Linux ARM64 | fitz-lang-linux-arm64.vsix |
| macOS Apple Silicon (M1/M2/M3) | fitz-lang-darwin-arm64.vsix |
Cada .vsix trae el fitz-lsp ya compilado adentro
(server/fitz-lsp[.exe]). No necesitás Rust ni Node.
- Instalalo en VSCode. Dos opciones equivalentes:
Desde la UI:
- Ctrl+Shift+P (o Cmd+Shift+P en macOS) → "Extensions: Install from VSIX..."
- Seleccioná el archivo bajado.
Desde la terminal:
- Abrí cualquier
.fitzy vas a ver highlighting + diagnostics + hover + go-to-def + autocomplete funcionando. Cero settings extra.
Las plataformas en la matriz de release son las 4 listadas arriba. macOS Intel (
darwin-x64) y Windows ARM64 (win32-arm64) no están: el primero por escasez crónica de runners macos-13 en GitHub Actions, el segundo porque axum aún no compila estable en ese target. Si necesitás alguna de esas, build local (sección B) funciona idéntico.¿Por qué no desde el VSCode Marketplace? Publicar al Marketplace requiere cuenta de publisher de Microsoft (vía Azure DevOps con PAT) — todavía no está creada. Cuando se cree, la instalación se va a hacer en un clic desde la UI de Extensions de VSCode buscando
fitz(la sección A se reemplazará por ese camino; releases en GitHub van a quedar como backup para air-gapped / corporate networks que bloquean el Marketplace).
B) Build local — .vsix para tu plataforma actual¶
Si querés trackear main o no encontrás tu plataforma en releases:
Esto produce editors/vscode/fitz-language-X.Y.Z-<platform>-<arch>.vsix
(ej. fitz-language-0.9.6-win32-x64.vsix, ~1.5 MB) y corre:
cargo build --release --features lsp(compilafitz-lsp).- Copia el binario a
editors/vscode/server/. tsccompila la extensión.vsce package --target <platform>empaqueta todo.
Después lo instalás como en (A):
Para empaquetar para otra plataforma (cross-compile, requiere
rustup target add <triple> previo):
node scripts/build-vsix.mjs --target linux-x64
node scripts/build-vsix.mjs --target darwin-arm64
# Plataformas soportadas: win32-x64, win32-arm64, linux-x64,
# linux-arm64, darwin-x64, darwin-arm64
C) Manual (alfa / desarrollo) — fitz-lsp en PATH o setting¶
Si querés iterar sobre el LSP sin re-empaquetar cada vez:
# Build local
cargo build --release --features lsp
# Instalar global (opcional, deja `fitz-lsp` en PATH)
cargo install --path . --features lsp
Después instalás una versión "delgada" del .vsix (sin binario
bundleado):
cd editors/vscode
npm install && npm run compile
npx @vscode/vsce package # sin --target, no bundlea
code --install-extension fitz-language-*.vsix
Si fitz-lsp no está en PATH, agregá esto al settings.json de
VSCode (Ctrl+, → "Open Settings (JSON)"):
La extensión sigue una cascada de resolución:
- Si setteás
fitz.lspPatha algo distinto del default — lo respeta (override manual). - Si no, busca
fitz-lspbundleado enserver/adentro del.vsix(modo bundled). - Como último fallback, busca
fitz-lspen elPATHdel sistema.
Si algo falla, abrí el output panel ("View → Output → Fitz Language Server") para ver qué dice.
Publicar al Marketplace (autor)¶
La publicación real al VSCode Marketplace es acción del autor, no del repo. Requiere:
- Cuenta de publisher: Microsoft account + Azure DevOps organization. Docs.
- Personal Access Token (PAT) con scope "Marketplace (manage)".
- Repo público: pre-requisito para que el
repositoryfield delpackage.jsonsea válido + para el Social Preview. vsce publishpor cada plataforma:
vsce publish --packagePath editors/vscode/fitz-language-X.Y.Z-win32-x64.vsix
vsce publish --packagePath editors/vscode/fitz-language-X.Y.Z-linux-x64.vsix
vsce publish --packagePath editors/vscode/fitz-language-X.Y.Z-darwin-arm64.vsix
# ... una por target
Marketplace muestra solo la versión apropiada al cliente que descarga.
Settings¶
| Setting | Default | Para qué |
|---|---|---|
fitz.lspPath |
"fitz-lsp" |
Path al binario. Default asume PATH. |
fitz.trace.server |
"off" |
Debug del protocolo LSP en el output panel. "verbose" muestra payloads JSON-RPC completos — útil si la extensión actúa raro. |
Otros editores¶
El protocolo LSP es estándar — cualquier editor con cliente LSP
puede usar fitz-lsp. La configuración varía por editor:
- Neovim: con
nvim-lspconfig, agregás un setup que apunte afitz-lsppara el filetypefitz. - Helix: en
languages.toml, definís[[language]]conname = "fitz"+[[language.language-server]]concommand = "fitz-lsp". - Zed: el extension API permite definir un language server personalizado.
La extensión VSCode es la única que mantenemos hoy en este repo; las demás integraciones quedan abiertas a contribuciones.
Estado del proyecto LSP¶
El plan LSP entero está cerrado: diagnostics (9.x.1), hover (9.x.2), go-to-definition (9.x.3), autocomplete contextual (9.x.4) y distribución multi-platform (9.x.5). Las cinco sub-fases visibles del LSP están vivas. La publicación real al Marketplace queda como acción del autor (ver sección anterior).
Si encontrás bugs en el LSP o sugerencias para la grammar TextMate (palabras que no se colorean, falsos positivos), abrí un issue en github.com/Thegreekman76/fitz.
23. fitz fmt — formateador automático¶
fitz fmt aplica un estilo canónico a tu código Fitz. Cero config:
no hay archivo .fitzfmt, no hay opciones de la CLI para indent o
comillas. La filosofía es la de gofmt — la uniformidad
cross-codebase vale más que la preferencia individual. Si discrepás
con una regla, abrí un issue; las reglas pueden ajustarse, pero
NO se configuran por proyecto.
Es production-ready: preserva los comentarios y blank lines del usuario, no solo el código.
Qué hace¶
- Pretty-printer sobre el AST. Re-emite el código siguiendo el
estilo canónico (4 espacios de indent, comillas dobles,
and/or/notcomo keywords, paréntesis obligatorios enif/while, type defs siempre multi-línea, un field por línea). - Preserva comments (
//y/* */) en cualquier posición: top-level, entre stmts, trailing. Los comments//foose normalizan a// foo(espacio post-//). Trailing comments tienen 2 espacios de separación:let x = 1 // explicación. - Preserva blank lines del usuario, con dos reglas: máximo 1
blank line consecutiva (las múltiples se colapsan), y blank
obligatoria entre
fnotypetop-level consecutivos. - Idempotente: aplicarlo dos veces produce el mismo resultado.
Si
fitz fmt --checkreporta diffs después de unfitz fmt, es un bug — reportalo.
Cómo se usa¶
# Formatear archivos explícitos in-place
fitz fmt src/main.fitz src/utils.fitz
# Formatear todo el proyecto (requiere fitz.toml)
fitz fmt
# Modo check para CI / pre-commit (read-only, exit 1 si hay diffs)
fitz fmt --check
fitz fmt --check src/main.fitz
Sin args, hace walk recursivo del proyecto excluyendo target/ y
directorios ocultos — necesita un fitz.toml en el cwd o un
ancestro (manifest mode). Con archivos explícitos no exige
manifest.
Cada archivo que cambia reporta ✓ formateado <path>; los que ya
estaban canónicos quedan silenciosos. Si un archivo tiene errores
de sintaxis, el formatter aborta para ese archivo con el error del
parser y sigue con los demás.
El estilo canónico (resumen)¶
| Aspecto | Regla |
|---|---|
| Indent | 4 espacios, no tabs |
| Strings | Comillas dobles siempre ("hola", nunca 'hola') |
| Operadores lógicos | and/or/not keywords |
if/while |
Paréntesis obligatorios en la condición |
for/loop |
Sin paréntesis |
| Type defs | Siempre multi-línea, un field por línea |
| Trailing commas | Solo en multi-línea (match arms, type fields) |
| Blank lines | Máximo 1 consecutiva. Obligatoria entre fn/type top-level |
Comments // |
Normalizados a // texto con espacio post-// |
La referencia completa, con los casos particulares (FnExpr inline, struct lits, method chains, etc.), vive en docs/fmt-style.md.
Ejemplo: antes y después¶
Input desformateado (src/main.fitz):
let users=[{"id":1,"name":"Ada"},{"id":2,"name":"Bob"}]
fn greet(u){return "Hola, {u.name}!"}//inline
for user in users{print(greet(user))}
Después de fitz fmt src/main.fitz:
let users = [{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bob"}]
fn greet(u) {
return "Hola, {u.name}!" // inline
}
for user in users {
print(greet(user))
}
Re-aplicar fitz fmt sobre el output no produce más cambios
(idempotencia).
Ejemplo: preservación de comments y blank lines¶
Input con comments en varias posiciones:
// Lista inicial de usuarios
let users = [
{"id": 1, "name": "Ada"}, // pionera
{"id": 2, "name": "Bob"},
]
// Helper de saludo
fn greet(u) {
// formato compacto
return "Hola, {u.name}!"
}
Tras fitz fmt, el output respeta los 3 comments (top-level,
trailing, y dentro del body) y la blank line entre el let y la
fn. Comments //foo sin espacio se normalizan a // foo.
Modo --check en CI¶
Para evitar drift de estilo en una codebase compartida, usalo en pre-commit hook o pipeline:
# .git/hooks/pre-commit
fitz fmt --check || {
echo "✗ código no formateado. Corré: fitz fmt"
exit 1
}
El exit code es 0 si todo está canónico, 1 si algún archivo difiere. Sin escribir nada.
Limitaciones conocidas¶
- No auto-wrappea líneas largas: 100 chars es soft limit, no enforced. El user decide cuándo partir una línea.
- Multi-líneas user-formateadas se colapsan: si formateaste una lista o un method chain en varias líneas para legibilidad y entran en una sola, el formatter las inlinea. Deuda futura.
- Comments adentro de expresiones (
f(x, // foo,y)): no soportados. Si aparecen, pueden quedar mal posicionados al re-formatear. - Comments entre el último stmt de un bloque y el
}: pueden terminar fuera del bloque al re-formatear. Caso raro. - Format-on-save desde el LSP: no conectado todavía.
fmt::format_sourcees library-able, así que el wiring desde el LSP es trivial — pendiente cuando aparezca demanda.
24. fitz test — testing built-in¶
Fitz trae test runner integrado: marcás una fn con @test, la
corrés con fitz test, y obtenés output estilo cargo con
ok/FAILED + exit code. Sin librerías, sin glue, sin elegir entre
3 frameworks.
Cuatro aserciones built-in¶
Disponibles globalmente, igual que print y len:
| Builtin | Qué hace | Falla si... |
|---|---|---|
assert(cond, msg?) |
Pasa si cond es true |
cond es false (mensaje opcional al final) |
assert_eq(a, b) |
Pasa si a == b |
distintos (output con left:/right: estilo cargo) |
assert_ne(a, b) |
Pasa si a != b |
iguales |
assert_throws(fn) |
Pasa si el callback tira un error | el callback retornó normal |
La igualdad de assert_eq/assert_ne es estructural recursiva:
funciona sobre primitivos, List, Map, Instance, Result. Coerciona
Int ↔ Float (igual que el == del lenguaje).
El decorator @test¶
Tres reglas del MVP:
- Sin args, sin kwargs:
@test fn foo() { ... }(sin paréntesis después de@test).@test() fn foo()también parsea por simetría con otros decorators. - La fn no recibe parámetros:
@test fn foo() { ... }. Si pasás params (fixtures), el evaluator aborta con mensaje claro. Las fixtures (@before_all,@before_each, etc.) son sub-paso futuro si aparece presión. - Async OK:
@test async fn carga() { let r = sleep(0).await }. El runner detectais_asyncyawait-ea elFutureresultante antes de reportar el test.
Fuera del runner (fitz run, fitz build), las @test fn son
no-op silencioso: no se ejecutan, no aparecen en el output.
Paralelo a #[cfg(test)] de Rust — el código de tests vive
junto al de producción, pero solo corre cuando lo pedís.
fitz test — el sub-comando¶
Dos modos de uso:
Single-file¶
Carga el archivo, descubre sus @test fn, las corre serie, reporta.
Manifest mode (proyecto)¶
Sin args, busca fitz.toml en el cwd o ancestros y descubre tests
automáticamente. Dos casos:
- Proyecto con
tests/*.fitz: carga cada archivo top-level detests/(no recursivo, ordenado alfabéticamente). Estos archivos típicamente importan la lib confrom <package-name> import X— el package se auto-registra en el resolver de deps (similar ause my_crate::*de Rust). - Proyecto solo con
[lib](sintests/): carga el[lib].entrydirectamente para descubrir@testinline. Útil para librerías pequeñas con tests pegados al código que prueban.
Si en un proyecto con tests integration el lib tiene @test
inline, esos tests se descubren solo si al menos un
tests/*.fitz importa la lib. El loader cachea por path
canonical, así que se ejecutan una sola vez aunque varios tests
los importen.
Filtrado por substring¶
Cargo style: substring case-sensitive sobre el nombre del test
(sin el prefijo del archivo). Los tests filtered out aparecen en
el output como running N tests (M filtered out).
Output¶
Estilo cargo:
running 3 tests
test src/lib.fitz::doble_funciona ... ok
test tests/math.fitz::doble_de_5 ... ok
test tests/math.fitz::doble_de_cero ... FAILED
failures:
---- tests/math.fitz::doble_de_cero stdout ----
Error — assert_eq falló:
left: 0
right: 1
failures:
tests/math.fitz::doble_de_cero
test result: FAILED. 2 passed; 1 failed; finished in 0.00s
Características:
- Prefijo del archivo: en manifest mode, cada test aparece como
<file>::<nombre>para localizar fallos rápido. En single-file mode, solo<nombre>. - Colores ANSI: verde para
ok, rojo paraFAILED. Se autodetecta si stdout es un TTY (std::io::IsTerminal); cuando redirigís a archivo o pipe, se omiten los códigos de color. - Exit code: 0 si todos pasan, 1 si al menos uno falla. Útil para CI.
Async tests¶
Las fns async funcionan transparente. El runner detecta is_async,
invoca, y await-ea el Future resultante antes de reportar el
test como ok/FAILED.
Restricción: assert_throws con callback async no funciona en el
MVP — el callback async fn produce un Future suelto que no es
equivalente a "tirar". Para casos async, usá match directo
sobre el Result de tu fn.
Estructura típica de un proyecto con tests¶
mi-proyecto/
├── fitz.toml
├── src/
│ └── lib.fitz # código de producción + opcional `@test` inline
└── tests/
├── math.fitz # integration tests, importan la lib
└── strings.fitz
fitz.toml:
[package]
name = "miproyecto" # ← sin hyphens, usable como ident en Fitz
version = "0.1.0"
edition = "2026"
[lib]
entry = "src/lib.fitz"
tests/math.fitz:
Nota sobre el nombre del paquete: el resolver de deps registra el
[lib].entrybajopackage.namepara que los tests integration puedan importarlo confrom <pkg> import X. Como Fitz no admite hyphens en identificadores, el nombre del paquete debe ser usable como identifier (miproyecto, nomi-proyecto). Refinamiento futuro si aparece presión.
Lo que NO anda todavía¶
- Fixtures (
@before_all,@before_each,@after_all,@after_each): post-MVP si aparece presión real. @benchpara benchmarks: post-MVP.- Mocks/spies built-in: NO — problema de ecosistema, no del lenguaje.
assert_throwscon callback async: rechazado en runtime; workaround conmatch.- Coverage: sub-paso futuro complejo (requiere instrumentación).
- Tests en paralelo: el runner corre serie. La paralelización llega si los tiempos de la suite duelen.
- Reporte de span del fallo: los errores de
assert*reportan el mensaje pero no la línea exacta de la aserción fallida. Refinamiento útil; necesita propagar el span del call site al builtin.
Ejemplo ejecutable¶
examples/guide/24-tests.fitz tiene un mini-set de tests sobre una
función factorial que podés correr así:
Tres tests pasan, uno falla intencional para que veas el formato de FAILED + summary final.
25. fitz dev — hot reload¶
fitz dev mantiene tu programa corriendo y lo re-arranca
automáticamente cuando guardás un cambio. Pensado para el loop
del developer: editás, guardás, ves el efecto, repetís.
Qué hace¶
- File watcher sobre el directorio del proyecto (manifest mode)
o el directorio del archivo (single-file mode). Usa el backend
nativo del SO via la crate
notify: FSEvents en macOS, inotify en Linux, ReadDirectoryChangesW en Windows. - Kill + respawn del proceso al detectar un cambio. Estrategia simple y correcta — incremental rebuild es deuda futura.
- Debounce 100ms para colapsar saves múltiples del editor (VSCode emite write tmp + rename + chmod en un save).
- Banner entre runs: clear screen (ANSI) + run number + target. Sin TTY (output redirigido), separa con líneas.
- Ctrl+C atrapado: mata el child antes de salir para evitar procesos zombie.
Cómo se usa¶
# Single-file mode: watch el parent del archivo
fitz dev --file mi_script.fitz
# Manifest mode: watch el dir del fitz.toml, corre el [bin].main
fitz dev
Qué dispara restart¶
Sólo archivos relevantes al lenguaje:
*.fitz(cualquier archivo Fitz, en cualquier subdirectorio).fitz.toml(cambios al manifest).
Se excluyen automáticamente:
target/(binarios y build artifacts defitz build)..git/(historia del repo).node_modules/(lock para devs con tooling JS al lado)..fitz/,dist/,build/(carpetas de output convencionales).- Cualquier carpeta o archivo oculto (empezado en
.).
Output típico¶
🔄 fitz dev — watching /path/to/mi_proyecto
ejecutando: proyecto `miapp`
(Ctrl+C para salir)
▶ fitz dev (run #1) — proyecto `miapp`
Hola mundo
42
✓ programa terminó OK (exit 0) — esperando cambios ...
↻ cambio detectado en src/main.fitz — reiniciando ...
▶ fitz dev (run #2) — proyecto `miapp`
Hola mundo modificado
84
✓ programa terminó OK (exit 0) — esperando cambios ...
Para programas HTTP, el comportamiento es análogo: el child arranca el servidor; al detectar cambio, lo mata + respawnea, así el nuevo código toma efecto sin re-ejecutar curl o refrescar el browser por varios segundos.
Lo que NO anda todavía¶
- Incremental rebuild (cambiar 1 archivo y re-cargar sólo eso): hoy es kill+respawn full. Mejora cuando aparezca el modelo de módulos pre-compilados.
- Browser auto-refresh para HTTP (inyectar WebSocket en respuestas): NO en MVP. Quien edite HTML/CSS junto al backend Fitz puede usar herramientas separadas (Live Server, etc.).
[dev]section enfitz.tomlpara configurar paths watched, debounce time, etc.: usamos defaults razonables. Sumar config si aparece demanda concreta.- Print de errors del checker mientras escribís sin disparar
restart: el child es quien imprime los errores de tipo en
arranque. La mitad del valor del
fitz deves que el typecheck ya estaba bloqueando los errores antes (modo strict delfitz run). Para feedback continuo sin restart, el LSP del cap 22 ya hace diagnostics in-editor. - Reload solo sobre cambios "significativos" (filtrar saves
sin cambios reales del contenido): hoy cualquier
Modifydel filesystem dispara. Si tu editor toca timestamps sin contenido real, vas a ver restarts spurios. Refinable si duele.
Cómo encaja con fitz test¶
Si querés ejecutar tus tests cada save, hoy hacés:
# Terminal 1: corré el programa principal
fitz dev
# Terminal 2: corré los tests con un wrapper simple
while true; do fitz test; sleep 2; done
Un sub-comando fitz dev --test que watchee y corra fitz test
en lugar de fitz run queda como sub-paso futuro si aparece
presión.
26. fitz repl — REPL interactivo¶
fitz repl abre un prompt donde podés ingresar expresiones y
statements línea por línea, viendo el resultado de cada uno
inmediatamente. Es el patrón "Read-Eval-Print Loop" — el mismo
que vive en Python/Node/Ruby.
Útil para:
- Aprender el lenguaje: probar expresiones sin armar un archivo.
- Debuggear: cargar tu lib con :load y ejercer fns una por una.
- Experimentar: ver tipos de expresiones con :type, listar
bindings con :env.
Cómo arrancarlo¶
Aparece el prompt:
Cada línea se evalúa contra un env compartido: lo que declarás en una línea sigue disponible en las siguientes.
Expresiones vs statements¶
El REPL imprime el valor de la expresión cuando es una expresión
top-level (Python style, sin print explícito):
fitz> 1 + 2
= 3
fitz> "hola, " + "fitz"
= "hola, fitz"
fitz> [1, 2, 3].map(fn(n) => n * 2)
= [2, 4, 6]
Los statements (let, fn, import, etc.) son silenciosos:
Multi-line continuation¶
Si tu input tiene {/(/[ sin cerrar, el prompt cambia a ...
y espera más:
fitz> fn factorial(n: Int) -> Int {
... if (n <= 1) {
... return 1
... }
... return n * factorial(n - 1)
... }
fitz> factorial(5)
= 120
La detección es por balanced brackets — el parser real puede aún emitir un error sintáctico distinto, que se muestra y volvés al prompt.
Async funciona¶
async fn y .await funcionan transparente en el prompt — el
REPL corre adentro de un runtime tokio:
fitz> async fn lento(n: Int) -> Int {
... let _ = sleep(10).await
... return n * 10
... }
fitz> lento(7).await
= 70
Comandos especiales¶
Toda línea que empieza con : es un comando del REPL, no código
Fitz:
| Comando | Qué hace |
|---|---|
:help, :h |
Lista de comandos. |
:quit, :q, :exit |
Sale. También Ctrl+D. |
:env |
Lista los bindings que definiste en esta sesión. |
:reset |
Limpia el scope: perdés todas las vars/fns. |
:type <expr> |
Muestra el tipo de una expresión. |
:load <archivo.fitz> |
Evalúa un archivo en el scope actual. |
Ejemplo de sesión usando casi todos:
fitz> let users = [{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bob"}]
fitz> users.len()
= 2
fitz> :type users
:: Map<Any, Any>... (deuda: ver "Lo que NO anda" más abajo)
fitz> :env
Definido en el scope:
users = [{"id": 1, "name": "Ada"}, ...] // List
fitz> :reset
✓ scope reseteado
fitz> :env
(scope vacío — no definiste nada todavía)
fitz> :quit
👋 hasta luego!
History persistente¶
Las líneas que tipeás se guardan en ~/.fitz/history (o
%USERPROFILE%\.fitz\history en Windows). En sesiones futuras:
- Flecha ↑/↓: navegar las líneas anteriores.
- Ctrl+R: buscar incrementalmente en la history.
- Home/End/Ctrl+A/Ctrl+E: mover el cursor.
- Ctrl+C: cancela el buffer actual (sale del multi-line si estaba abierto), volvés al prompt.
- Ctrl+D: sale del REPL.
(Todo esto lo aporta rustyline, el mismo crate que usa
cargo-edit y otros REPLs Rust.)
Lo que NO anda todavía¶
:typescope-aware: hoy:type xconlet x = 5previo devuelveAny. El comando arma un programa sintético independiente y el checker no ve las vars previas del REPL. Refinable feedeando el env al checker — sub-paso futuro si aparece presión real.:loadcon paths relativos: usá paths absolutos o relativos al directorio donde arrancastefitz repl. No hay autocompletion de paths.- Manifest mode:
fitz replsin args es siempre single-file (sin manifest). Si querés cargar tu proyecto, usá:load src/lib.fitz. - Pretty-print por defecto: las instancias se imprimen estilo
Display (que es el de
print). Para JSON formateado, llamá manualmente a tu helper. - Indentación automática en multi-line: el prompt
...no ajusta indent. Lo tipeás vos. - Comandos
:save <archivo>(volcar la sesión a un .fitz),:undo(deshacer última línea),:debug(modo verbose): sub-pasos futuros si entra demanda.
Cuándo usar fitz repl vs fitz run¶
| Caso | Usá |
|---|---|
| Probar una expresión sin armar archivo | fitz repl |
| Aprender el lenguaje, experimentar | fitz repl |
| Debuggear una fn aislada con varios inputs | fitz repl + :load |
| Correr tu programa principal | fitz run |
| Loop de desarrollo con auto-restart al guardar | fitz dev |
| Correr los tests | fitz test |
27. fitz lint — linter de patrones¶
fitz lint detecta patrones que sí compilan pero son code smells:
vars no usadas, imports muertos, match con un solo arm catch-all,
concatenación de strings con + en lugar de interpolación. El
linter complementa al type checker:
fitz check— errores de tipo (bloqueantes, exit 1).fitz lint— sugerencias de estilo/patrón (warnings, exit 0 por default). Promovés a error con--deny <lint>en CI.
Los 4 lints del MVP¶
| Lint | Qué detecta |
|---|---|
unused_variable |
let x = ... cuyo nombre no aparece en ningún uso (skip prefijo _). |
unused_import |
import X o from X import Y cuyo binding no se usa. |
useless_match |
match expr { _ => body } con un solo arm catch-all (= un let directo). |
string_concat |
"a" + "b" con ambos operandos literales (usá interpolación). |
Cómo se usa¶
# Lintear archivos explícitos
fitz lint src/main.fitz src/utils.fitz
# Lintear todo el proyecto (requiere fitz.toml)
fitz lint
# Tratar un lint como error (CI)
fitz lint --deny unused_variable
fitz lint --deny unused_variable --deny string_concat # múltiples
Default: warnings, exit 0 incluso con findings. Solo el flag
--deny <name> con un lint que aparezca convierte el exit a 1.
Supresión: // @allow(<lint>)¶
Si un lint es intencional, prefijás la línea anterior con un
comment @allow(<name>):
Solo la línea inmediatamente anterior cuenta. El comment puede
tener texto adicional (// @allow(unused_variable) — pending fix).
Output¶
Estilo cargo-clippy:
warning: variable `temp` declarada pero no usada [unused_variable]
--> src/main.fitz:7:5
= nota: si es intencional, prefijá con `_` (ej. `_temp`) o suprimí con
`// @allow(unused_variable)` en la línea anterior.
warning: concatenación de strings literales — usá interpolación [string_concat]
--> src/main.fitz:4:24
= nota: reemplazá `"a" + "b"` con `"ab"` (o usá interpolación
`"{a}{b}"` si los lados son variables).
2 findings en 1 archivo(s)
Colores ANSI auto cuando stdout es TTY: amarillo para warning,
rojo para error (modo --deny).
Lo que NO anda todavía¶
- Auto-fix (
--fix): el roadmap lo menciona como flag opcional. En el MVP, todos los lints emiten sugerencias textuales pero no modifican código.string_concates el candidato natural a auto-fix (sub-paso futuro). - Lints adicionales:
redundant_clonenecesita análisis de movimientos que el compilador todavía no hace.panic_in_test_onlyno aplica (Fitz no tiene unpanic!distinguido — los asserts son builtins normales). - Catálogo extensible (plugins): por ahora los 4 lints viven
hardcoded en
src/lint.rs. Plugins externos no son scope del MVP. - Sub-stmt suppression: el
// @allow(<name>)solo afecta la línea siguiente. No hay// @allow(name) { ... }para suprimir un bloque entero. - Análisis cross-scope estricto:
unused_variableusa un set global de uses — no detecta shadowing (let x = 5; let x = 10; xno reporta el primerxcomo unused aunque técnicamente sí es). Refinamiento si aparece presión.
Integración con CI¶
Patrón típico en pre-commit o pipeline:
fitz fmt --check # exit 1 si hay diffs
fitz check # exit 1 si hay errores de tipo
fitz lint --deny unused_variable --deny unused_import # exit 1 si los hay
fitz test # exit 1 si algún test falla
Solo fitz lint permite ser laxo por default (warnings sin
romper). El resto siempre exige cero issues.
28. Auth nativa¶
En FastAPI/Spring/ASP.NET/Express la auth se resuelve con librerías
opcionales: fastapi-users + python-jose + passlib en Python;
@PreAuthorize de Spring AOP + reflection en Java; passport.js +
bcrypt + jsonwebtoken en Node. Cinco dependencias mínimo,
configuración manual, y validación que sucede en runtime —
descubrís que tu handler protegido no recibe el user cuando ya
está corriendo en prod.
En Fitz auth es parte del lenguaje: tres decoradores
(@auth_provider, @authenticated, @admin), dos módulos
built-in (jwt, hash), y el type checker valida en compile-time
que cada handler protegido reciba un user del tipo correcto. Cero
fitz add jsonwebtoken / argon2 / nada. El esquema OpenAPI 3.1
generado documenta automáticamente el requirement de bearer token
y los códigos 401/403. Y todo eso vale igual en fitz run y en el
binario nativo de fitz build — paridad bit-a-bit, deploy
standalone sin instalar runtime.
Las piezas¶
@auth_provider — singleton del programa. Una fn que toma los
headers HTTP del request, decide si el usuario está autenticado, y
devuelve el User correspondiente:
type User { id: Int, email: Str, name: Str, role: Str }
@auth_provider
fn check_token(headers: Map<Str, Str>) -> Result<User> {
let auth = headers.get("authorization")?
let claims = jwt.decode(auth, "secret")?
let email = claims["email"]
// ... lookup en DB / hardcoded ... devuelve `Ok(User { ... })`
return Err("auth inválida")
}
El checker valida estáticamente:
- Que el provider tenga exactamente un param
Map<Str, Str>. - Que retorne
Result<T>dondeTes un type custom (nominal). - Que sea único por programa (un solo provider; otro = error).
@authenticated — sobre un handler HTTP. El runtime corre el
provider antes del handler; si devuelve Err, responde 401 con
{"error": <msg>} y el handler no ejecuta. Si devuelve Ok(user),
el user se inyecta como argumento del handler:
@admin — shorthand de @authenticated + check user.role
== "admin". Si el provider autentica pero el rol no es "admin",
el runtime responde 403. El checker exige que el tipo User
(retornado por el provider) tenga campo role: Str:
@requires("role") (Fase 9.w.1.iter2) — RBAC custom apilable
para roles más allá de admin. El runtime ejecuta el provider e
inyecta el user; después verifica que user.role matchee al
menos uno de los roles requeridos. Si no, 403 con el role
actual y los requeridos en el mensaje. Apilable para OR:
@requires("editor")
@post("/articles")
fn create(body: Article, user: User) -> Article {
// Solo si user.role == "editor"
return body
}
@requires("editor")
@requires("publisher")
@put("/articles/{id}")
fn publish(id: Int, user: User) -> Article {
// Si user.role == "editor" O user.role == "publisher"
...
}
Como @admin, @requires exige role: Str en el User type
del provider (no nullable). Es independiente de @authenticated
— pero implica auth (el wrapper corre el provider igual). El
mensaje de 403 cita el role actual del user y la lista de roles
requeridos, útil para debug y observabilidad.
jwt — módulo built-in. Siempre disponible, sin import.
let token = jwt.encode({"sub": "u42", "role": "admin"}, "mi-secret")
let claims = jwt.decode(token, "mi-secret")? // Result<Map<Str, Str>>
HS256 default; opcional pasar "HS384"/"HS512" como tercer arg.
encode devuelve el JWT firmado como Str; decode siempre
devuelve Result<Map<Str, Str>> — token malformado, signature
inválida, expirado son runtime events que el caller maneja con
match o ?.
hash — módulo built-in. Argon2id (recomendación OWASP), salt
aleatorio por hash, output en formato PHC string listo para
guardar en DB:
let hashed = hash.password("supersecret")
// → "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>"
let ok = hash.verify("supersecret", hashed) // Bool
verify siempre devuelve Bool (no Result): hash malformado o
mismatch → false por seguridad, no se filtra info al attacker.
auth (Fase 9.w.1.iter2.b) — módulo built-in para token
revocation. 3 builtins async que operan sobre Postgres con la tabla
fitz_token_blacklist(jti TEXT PRIMARY KEY, expires_at BIGINT NOT NULL)
auto-creada al primer call:
// Revocar un token (típico: handler /auth/logout):
auth.blacklist(db, jti, exp).await? // Result<Null>
// Chequear si un token está revocado (típico: @auth_provider):
let revoked = auth.is_blacklisted(db, jti).await? // Result<Bool>
// Limpiar tokens vencidos (típico: @cron periódico):
let deleted = auth.cleanup_expired(db).await? // Result<Int>
Decisiones de diseño:
expires_atcomo Unix epoch (BIGINT): el JWTexpclaim ya viene como timestamp Unix, evita conversiones.- Auto-filtrado de tokens vencidos en
is_blacklisted: el SQL usaexpires_at > extract(epoch from now()). Tokens conexppasado cuentan como NO blacklisted (eljwt.decodelos rechaza primero por expirados). ON CONFLICT DO UPDATEen blacklist: re-blacklistear el mismo jti actualizaexpires_atsin fallar (caso raro pero posible).- Tabla auto-creada con
CREATE TABLE IF NOT EXISTSal primer call de cualquier builtin — idempotente, Postgres serializa con LOCK interno. - Paridad bit-a-bit
fitz run↔fitz build— el codegen emite helpers__fitz_auth_*que comparten el mismo SQL que el intérprete.
Patrón canónico — /auth/logout + /auth/refresh + provider:
Los endpoints se escriben a mano (no hay auto-mount como
/healthz); el lenguaje provee los builtins, el user el flow. Lean
y honesto: cubre el caso 90% en ~30 LoC.
type User { id: Int, name: Str, role: Str }
type LoginRequest { email: Str, password: Str }
type LoginResponse { token: Str }
@auth_provider
async fn check(headers: Map<Str, Str>) -> Result<User> {
let token = extract_bearer(headers)?
let claims = jwt.decode(token, secret(), "HS256")?
let jti = claims.get("jti")?
// CHEQUEO DE BLACKLIST. Si el token fue revocado, rechazá.
if auth.is_blacklisted(db, jti).await? {
return Err("token revocado")
}
let user_id = claims.get("sub")?
return resolve_user_from_db(user_id).await
}
@post("/auth/login")
async fn login(body: LoginRequest) -> Result<LoginResponse> {
let user = verify_credentials(body.email, body.password).await?
let jti = uuid.v4() // único por token
let exp = now_unix() + 3600 * 24 // 24h validity
let token = jwt.encode({
"sub": user.id,
"jti": jti,
"exp": exp,
"role": user.role
}, secret())
return Ok(LoginResponse { token: token })
}
@authenticated
@post("/auth/logout")
async fn logout(user: User, headers: Map<Str, Str>) -> Result<Null> {
let token = extract_bearer(headers)?
let claims = jwt.decode(token, secret())?
let jti = claims.get("jti")?
let exp = claims.get("exp").parse_int()?
auth.blacklist(db, jti, exp).await?
return Ok(null)
}
@authenticated
@post("/auth/refresh")
async fn refresh(user: User, headers: Map<Str, Str>) -> Result<LoginResponse> {
// Estrategia simple: revocar el token actual y emitir uno nuevo.
let token = extract_bearer(headers)?
let old_claims = jwt.decode(token, secret())?
auth.blacklist(db, old_claims.get("jti")?, old_claims.get("exp").parse_int()?).await?
let new_jti = uuid.v4()
let new_exp = now_unix() + 3600 * 24
let new_token = jwt.encode({
"sub": user.id,
"jti": new_jti,
"exp": new_exp,
"role": user.role
}, secret())
return Ok(LoginResponse { token: new_token })
}
// Cleanup periódico de tokens vencidos.
@cron("0 0 3 * * *") // 3 AM daily
async fn cleanup_blacklist() -> Result<Null> {
let deleted = auth.cleanup_expired(db).await?
log.info("blacklist cleanup", deleted_rows: deleted)
return Ok(null)
}
Lo que NO hace el MVP de 9.w.1.iter2.b:
- Auto-mount de
/auth/logouty/auth/refresh— los escribís a mano (~10 LoC cada uno). Razón: el flow exacto (qué returneás, qué validás, qué loguéas) varía por proyecto. Auto-mount sería opinión fuerte que después es difícil de deshacer. auth.blacklistsíncrono (sin DB) — el MVP requiere Postgres vivo. Para in-memory rápido, usá unMap<Str, Int>global y chequeálo a mano en el@auth_provider. Trade-off: no persiste entre restarts.- Token rotation con refresh tokens dedicados — el MVP usa un
solo token largo (~24h) que se reemplaza en
/auth/refresh. Refresh tokens dedicados (modelo OAuth2 clásico) quedan como pattern futuro si entra demanda.
Ejemplo completo: login + /me + /admin¶
examples/guide/28-auth.fitz arma una API mini-realista con los
tres roles que cubren la mayoría de casos reales:
POST /login— público; recibe credenciales, verifica el password contra el hash almacenado conhash.verify(...), y devuelve un JWT firmado conjwt.encode(...).GET /me— requiere@authenticated; devuelve eluserinyectado.GET /admin/users— requiere@admin; devuelve la lista de todos los usuarios. Si el token es de un user con rol distinto a"admin", 403.
El ejemplo entero compila a binario nativo con fitz build. Una
sesión típica:
# 1. Login para obtener un token.
$ curl -X POST localhost:43928/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"secret-ada-123"}'
{"token":"eyJ0eXAiOi..."}
# 2. /me con el token.
$ curl localhost:43928/me -H 'Authorization: Bearer eyJ0eXAiOi...'
{"id":1,"email":"ada@example.com","name":"Ada","role":"admin"}
# 3. /admin/users con token de admin → 200.
$ curl localhost:43928/admin/users -H 'Authorization: Bearer eyJ0eXAiOi...'
[{"id":1,"email":"ada@example.com",...},{"id":2,...}]
# 4. /admin/users con token de user-rol → 403.
$ curl localhost:43928/admin/users -H 'Authorization: Bearer <alan-token>'
{"error":"acceso prohibido — se requiere rol admin"}
# 5. /me sin token → 401.
$ curl localhost:43928/me
{"error":"falta header Authorization"}
El ejemplo combina todo el stack en menos de 100 líneas: tipos
Fitz (type User { ... }), decoradores apilados (@auth_provider
+ @authenticated/@admin + @get/@post), body HTTP
deserializado a tipos custom (Credentials), Result<T> con ?,
return <status> { ... } para emitir status codes explícitos,
JWT firmado y verificado con HS256, password hashing con Argon2id.
Cero pip install, cero cargo add, cero npm install.
Integración con OpenAPI¶
Cuando el programa declara @authenticated/@admin, el schema
OpenAPI generado en /openapi.json agrega automáticamente:
{
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"paths": {
"/me": {
"get": {
"security": [{"bearerAuth": []}],
"responses": {
"200": { "...": "..." },
"401": { "...": "..." }
}
}
},
"/admin/users": {
"get": {
"security": [{"bearerAuth": []}],
"responses": {
"200": { "...": "..." },
"401": { "...": "..." },
"403": { "...": "..." }
}
}
}
}
}
La UI de Scalar en /docs muestra el lock icon en cada handler
protegido y un campo para pegar el bearer token — listo para
probar la API sin escribir un solo cliente.
Por qué Fitz hace esto distinto¶
- Estático, no reflection: el checker valida en compile-time
que
@authenticated/@admintengan un@auth_providerregistrado y que eluser: Userdel handler matchee elUserdel provider. Spring AOP / ASP.NET[Authorize]resuelven esto en runtime con reflection; cuando rompe, rompe en prod. - Zero dependencies:
jwt,hash, JWT signing, password hashing — todo viene en el binariofitzy se incluye en el binario nativo defitz build. No hayrequirements.txt,package.json,Cargo.tomlque mantener. Deploy = un binario. - Paridad bit-a-bit
fitz run↔fitz build: el flow de auth funciona idéntico en el intérprete (rapid feedback durante dev) y en el binario nativo (deploy a prod). Cero "anda en local pero no en server". - OpenAPI auto-documentado: el security scheme + los códigos
401/403 + el bearer requirement por handler se generan del
código fuente. No hay que escribir specs OpenAPI a mano (vs
Express+
swagger-ui-express+manual specs). - Integrado con el resto del lenguaje: tipos custom +
Result<T> - decoradores apilables + middleware/CORS + body deserialization
return <status> { ... }— todas las features previas siguen funcionando dentro de un handler protegido. No es una "isla" de auth con sus propias reglas.
Qué no está en el MVP¶
Estos items están comprometidos como deuda explícita:
- Sessions cookie-based como alternativa a JWT. JWT primero (stateless, simple). Sessions persistentes requieren DB nativa (Fase 10).
- RBAC con múltiples roles. Hoy hay solo
@authenticatedy@admin. Custom decorators de auth con su propia lógica de permisos llegan post-Fase 10. - Provider request-aware más allá de los headers (e.g. acceso
al body o al método HTTP). Hoy el provider recibe solo
Map<Str, Str>de headers. - Token refresh / revocación. JWT del MVP es stateless puro;
para revocar antes del
exphace falta una blacklist server-side (Fase 10 con DB). - Asimétricos (RS256/ES256). Hoy HS256/384/512 (HMAC con secret compartido). Asimétricos requieren parsear PEM y rotación de llaves — post-MVP.
Detalle completo en docs/roadmap.md para refinamientos pendientes
y planes de endurecimiento para servicios críticos.
29. WebSockets tipados¶
En Socket.IO/Phoenix Channels/ASP.NET SignalR/FastAPI WebSocket los
WS arrancan con un grado de tipado parcial y se completan a mano:
en FastAPI el handler recibe WebSocket opaco y vos parseás el
JSON con json.loads + Pydantic; en Socket.IO los eventos son
strings sin schema; SignalR genera proxies tipados solo en C#; el
schema de los mensajes (qué frames van, qué frames vienen) vive
en un README al lado del código o en una doc que se atrasa.
Encima el heartbeat para pasar proxies idle-killers (Nginx 60s,
Cloudflare ~100s, ALB 60s) lo configurás vos en cada proyecto, y
la auth pre-upgrade del handshake es código manual.
En Fitz los WebSockets son parte del lenguaje con tipado
end-to-end: declarás un type ChatMsg { ... }, declarás el
handler como fn chat(conn: WsConn<ChatMsg>, ...), y cada frame
text se serializa/deserializa al type automáticamente. El
heartbeat se configura con un kwarg
(@server(ws_heartbeat_secs=30)) y los Ping frames se mandan
solos. El AsyncAPI 3.0 del canal — la spec equivalente a
OpenAPI pero para event-driven — se genera del código fuente en
/asyncapi.json. Los decoradores @authenticated / @admin
apilados sobre @ws validan el bearer token ANTES del HTTP
upgrade, devolviendo 401/403 sin abrir el socket. Y todo eso
vale igual en fitz run y en el binario nativo de fitz build —
paridad bit-a-bit, cero cargo add tokio-tungstenite o
pip install websockets.
Las piezas¶
@ws("/path") — declara un handler WebSocket. El handler es
una async fn que recibe un WsConn<T> como primer param, y
opcionalmente un user: User si está protegido por
@authenticated/@admin. T es el tipo de cada frame text;
puede ser un type custom (ChatMsg) o cualquier tipo
serializable a JSON.
type ChatMsg { user: Str, text: Str }
@ws("/chat")
async fn chat(conn: WsConn<ChatMsg>) -> Null {
loop {
match conn.recv() {
Ok(msg) => { let _ = conn.broadcast(msg) }
Err(_) => return null
}
}
return null
}
El checker valida estáticamente:
- Que el handler sea
async fn(los WS son intrínsecamente async). - Que el primer param sea
WsConn<T>conTresoluble. - Que retorne
Null(el ciclo de vida del WS lo maneja el runtime). - Si está bajo
@authenticated/@admin, que reciba elUserdel@auth_providerregistrado (igual que con@get/@post).
WsConn<T> — conexión activa. Cuatro métodos:
conn.recv() -> Result<T>— bloquea hasta el próximo frame text. DevuelveOk(<T>)con el mensaje deserializado, oErr(e)si la conexión se cierra o el frame no parsea contraT.conn.send(msg: T) -> Result<Null>— manda un frame text al cliente actual (solo al sender, no a otros).conn.broadcast(msg: T) -> Result<Null>— manda el mismo frame text a TODOS los clientes conectados al mismo endpoint — incluido el sender, convención Socket.IO/Phoenix.conn.close() -> Result<Null>— cierra la conexión explícitamente. Opcional: salir del handler también cierra.
@server(ws_heartbeat_secs=N) — configura el intervalo de
ping. Default 30s (el más bajo de los proxies comunes). Cada N
segundos el runtime manda un Ping frame por cada conexión viva;
si el cliente no responde Pong, el sink falla en el próximo
write y el writer task termina limpio. N=0 desactiva el
heartbeat (no recomendado en deploys con proxies).
Ejemplo completo: chat broadcast con auth¶
examples/guide/29-ws.fitz arma un servidor de chat completo en
~100 líneas que combina todo el stack de Fitz:
POST /login(HTTP plano) verifica password conhash.verify(...)(Argon2id) y devuelve un JWT firmado conjwt.encode(...)(HS256).@auth_provider fn check_token(headers)corre antes de CADA request@authenticated— HTTP y WS. Para el WS, corre en el handshake antes del upgrade.@authenticated @ws("/chat")decora el handler conWsConn<ChatMsg>. Cada frame text se deserializa aChatMsgautomático;conn.broadcast(msg)envía a todos los clientes conectados.@server(43929, ws_heartbeat_secs=30)configura puerto y heartbeat.
Una sesión típica:
# 1. Login para obtener un token.
$ curl -X POST localhost:43929/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"secret-ada-123"}'
{"token":"eyJ0eXAi..."}
# 2. Conectar dos clientes WS distintos (en dos terminales), cada
# uno con SU bearer token. Cualquier mensaje JSON enviado se
# broadcastea a TODOS los clientes — incluido el sender.
$ wscat -c "ws://localhost:43929/chat" \
-H "Authorization: Bearer eyJ0eXAi..."
> {"user":"Ada","text":"hola"}
< {"user":"system","text":"bienvenido Ada"}
< {"user":"Ada","text":"hola"} # del broadcast, con user.name forzado del JWT
# 3. Auth pre-upgrade — token inválido → 401 SIN abrir el socket.
$ wscat -c "ws://localhost:43929/chat" \
-H "Authorization: Bearer fake"
error: Unexpected server response: 401
El ejemplo entero compila a binario nativo con fitz build y
producía output bit-a-bit idéntico al intérprete.
Integración con AsyncAPI¶
OpenAPI 3.1 documenta endpoints HTTP request/response. AsyncAPI
3.0 documenta canales event-driven — la spec hermana para WS,
MQTT, Kafka, etc. Cuando el programa declara @ws(...), el
runtime genera automáticamente el schema en /asyncapi.json:
{
"asyncapi": "3.0.0",
"info": { "title": "Fitz API", "version": "0.1.0" },
"channels": {
"/chat": {
"messages": {
"ChatMsg": {
"name": "ChatMsg",
"payload": {
"type": "object",
"properties": {
"user": { "type": "string" },
"text": { "type": "string" }
},
"required": ["user", "text"]
}
}
}
}
},
"operations": {
"receive_/chat": {
"action": "receive",
"channel": { "$ref": "#/channels/~1chat" }
},
"send_/chat": {
"action": "send",
"channel": { "$ref": "#/channels/~1chat" }
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
}
}
Tooling AsyncAPI (AsyncAPI Studio en asyncapi.com/studio,
generadores de clientes para JS/TS/Python/Java) consume este
schema directo — escribís type ChatMsg { ... } una vez y los
clientes en otros lenguajes se generan solos.
UI embebida en /asyncapi — paralelo al /docs del
OpenAPI/Scalar. Cuando hay handlers @ws, el server autoregistra
dos rutas:
GET /asyncapi.json— schema crudo.GET /asyncapi— UI HTML embebida usando@asyncapi/react-component(cargado vía CDN). Renderiza channels, operations, mensajes con sus schemas, security requirements. Útil para devs que consumen el spec o testean endpoints WS.
Igual que el /docs del OpenAPI: si el usuario declara su propio
@get("/asyncapi"), el auto-register cede al handler del user.
Opt-out global de ambas UIs con @server(docs=false).
Por qué Fitz hace esto distinto¶
Cinco diferenciales que vuelven a Fitz único en el espacio de WebSockets tipados:
- Marshaling JSON automático — declarás
WsConn<ChatMsg>y cada frame text se serializa/deserializa aChatMsgsin unjson.loads+ Pydantic /JSON.parse+ Zod /serde_jsonmanual. El mismo trait que sirve los handlers HTTP (__ToFitzJson/__FromFitzJson) cubre WS, primitivos, types custom, listas, mapas, Result. Si el frame no parsea contraT,recv()devuelveErr— error de runtime esperable, no panic. - AsyncAPI auto-generado — el schema en
/asyncapi.jsonsale del código fuente. No hay que mantener un YAML AsyncAPI a mano (vs Socket.IO/Phoenix/SignalR/FastAPI WebSocket, donde el schema vive en un README al lado del código o en una doc que atrasa). Tooling AsyncAPI estándar (Studio, generadores de clientes) consume el schema directo. - Heartbeat built-in —
@server(ws_heartbeat_secs=N)y listo. El runtime manda los Ping frames cada N segundos por cada conexión viva, sin código del usuario. Pasa de largo Nginx (60s default idle), Cloudflare (~100s), AWS ALB (60s). - Auth integrada —
@authenticated/@adminapilados sobre@wsvalidan el bearer token ANTES del HTTP upgrade. El cliente recibe 401/403 sin que se abra el socket — menos attack surface, menos recursos consumidos. El checker valida en compile-time que el handler reciba elUsercorrecto del@auth_providerregistrado. - Codegen con paridad — el flow WS funciona idéntico en
fitz run(intérprete, feedback rápido durante dev) y enfitz build(binario nativo standalone, deploy a prod). Cero "anda en local pero no en server". Cerocargo add tokio-tungsteniteopip install websockets.
Ningún otro lenguaje hoy combina WS tipados con AsyncAPI
auto-generado del código fuente, heartbeat built-in y auth
integrada en el handshake. FastAPI WebSocket te da Pydantic y
schema manual; Socket.IO te da eventos sin schema; Phoenix
Channels te da pattern matching tipado pero solo en Elixir;
SignalR te da proxies tipados solo en C# y solo en .NET. Fitz
los junta en un lenguaje nuevo, con un binario standalone, con
auth nativa.
Auth WS desde browsers — subprotocol bearer.<token>¶
new WebSocket(url) del browser no permite setear headers HTTP
arbitrarios. El segundo argumento del constructor SÍ acepta una
lista de subprotocols. La convención estándar (Socket.IO, Phoenix,
varios proyectos Node) es pasar el token via subprotocol con
formato bearer.<token>.
Fitz lo soporta sin cambios del lado user — el runtime y el
codegen extraen el token del header Sec-WebSocket-Protocol y
lo inyectan como authorization: Bearer <token> al map de
headers que ve el @auth_provider. El mismo provider funciona
para HTTP y WS.
Cliente browser:
const token = "eyJhbGc..."; // JWT obtenido vía POST /login
const ws = new WebSocket("ws://localhost:43929/chat", `bearer.${token}`);
ws.onmessage = (ev) => console.log("got:", ev.data);
ws.onopen = () => ws.send(JSON.stringify({user: "Ada", text: "hola"}));
Server Fitz — sin cambios respecto al cap 28:
@auth_provider
fn check_token(headers: Map<Str, Str>) -> Result<User> {
let auth = match headers.get("authorization") {
Ok(v) => v,
Err(_) => return Err("falta Authorization"),
}
// auth = "Bearer eyJhbGc..." (inyectado por el runtime desde
// el subprotocol, sin que el handler lo sepa).
let token = auth.replace("Bearer ", "")
let claims = jwt.decode(token, SECRET)?
return find_user(claims["email"])
}
@authenticated
@ws("/chat")
async fn chat(conn: WsConn<ChatMsg>, user: User) -> Null { /*...*/ }
Echo del subprotocol: RFC 6455 §4.1 exige que el server
confirme el subprotocol elegido en la response del handshake. Sin
echo, el browser rechaza el upgrade. Fitz lo hace automático —
si el cliente envió bearer.<token>, el server responde con el
mismo subprotocol en el handshake.
Compatibilidad con header: si el cliente envía AMBOS
(Authorization: Bearer ... y Sec-WebSocket-Protocol: bearer.<token>),
el header gana — el subprotocol solo inyecta cuando no hay
Authorization previo. Esto preserva el caso wscat/curl/clientes
no-browser que sí pueden setear headers.
Paridad bit-a-bit fitz run ↔ fitz build.
Canales asimétricos con WsConn<In, Out>¶
Para canales donde el cliente envía un tipo y el server emite otro
distinto (e.g. cliente manda comandos Str, server emite eventos
ChatMsg estructurados), declarás WsConn<In, Out> con dos
type params en lugar del simétrico WsConn<T>:
type ChatMsg { user: Str, text: Str }
@ws("/cmd")
async fn cmd(conn: WsConn<Str, ChatMsg>) -> Null {
let welcome = ChatMsg { user: "system", text: "conectado" }
let _ = conn.send(welcome)
loop {
match conn.recv() {
Ok(input) => {
// input: Str (el `In` del WsConn<In, Out>)
let reply = ChatMsg { user: "system", text: "got:{input}" }
let _ = conn.send(reply) // espera ChatMsg (el `Out`)
}
Err(_) => return null
}
}
return null
}
conn.recv() devuelve Result<In>; conn.send(msg) y
conn.broadcast(msg) aceptan Out. El checker valida los tipos
por dirección, así que conn.send("hola") sobre WsConn<Str, ChatMsg>
falla en compile-time con mensaje claro: el send espera ChatMsg,
no Str.
Compat hacia atrás: WsConn<T> (aridad 1) es equivalente a
WsConn<T, T> y todos los handlers existentes siguen funcionando
idéntico.
AsyncAPI asimétrico: cuando In != Out, el schema autogenerado
emite dos messages distintos: msg_in (referenciado por la
operation receive, payload schema de In) y msg_out (referenciado
por la operation send, payload schema de Out). Generadores de
clientes AsyncAPI producen interfaces tipadas separadas para entrada
y salida, paralelo al modelo de gRPC Server-Streaming.
Restricción binary mixto: si In o Out es Bytes pero el
otro no, el codegen rechaza con error explícito (canal mixto
binary/text es deuda residual). WsConn<Bytes, Bytes> y
WsConn<Bytes> simétrico funcionan; WsConn<Bytes, Str> no.
Paridad bit-a-bit fitz run ↔ fitz build (el preludio Rust usa
__FitzWsConn<RECV, SEND> con 2 type params; monomorfismo hace
que canales simétricos WsConn<T> produzcan binarios idénticos al
pre-bidir).
Ejemplo runnable completo:
examples/guide/29c-ws-bidir.fitz.
Frames binarios con WsConn<Bytes>¶
Cuando el wire es binario raw — protocolos custom (gRPC-Web,
protobuf, MessagePack, CBOR), streaming de audio/video, file
transfer chunk-a-chunk — WsConn<Bytes> opera con Vec<u8> en
ambas direcciones, sin re-encoding a JSON ni base64:
@server(43930)
fn main() => 0
@ws("/raw")
async fn raw(conn: WsConn<Bytes>) -> Null {
loop {
match conn.recv() {
Ok(buf) => {
let _ = conn.send(buf) // echo binario puro
}
Err(_) => return null
}
}
return null
}
conn.recv() espera Message::Binary (no Message::Text) y lo
devuelve como Result<Bytes>. conn.send(buf) y
conn.broadcast(buf) emiten Message::Binary raw. Mismatch
entre frame type esperado y recibido → Err con mensaje claro
(usá WsConn<Bytes> para binary; WsConn<Str> o nominal para
text).
El schema AsyncAPI 3.0 auto-generado refleja el modo binario:
{
"channels": {
"/raw": {
"messages": {
"msg": {
"contentType": "application/octet-stream",
"payload": { "type": "string", "format": "binary" }
}
}
}
}
}
Tools como AsyncAPI Studio o generadores de clientes binarios lo reconocen y renderean el endpoint como "binary upload" en vez de schema JSON convencional.
Paridad bit-a-bit fitz run ↔ fitz build: el binario nativo
producido por fitz build tiene un struct dedicado
__FitzWsConnBytes (no genérico) y un setup helper aparte —
specialization sobre __FitzWsConn<Vec<u8>> no funciona porque el
blanket impl del trait interno trataría Vec<u8> como
List<Int> JSON. Dos structs dedicados son la solución más
simple y explícita.
Trade-off del modelo: un endpoint es text-only XOR binary-only.
Para canales mixtos (texto + bytes en el mismo socket), declarás
dos endpoints separados (@ws("/raw") para bytes, @ws("/json")
para text). Si aparece presión real por mixto en el mismo
endpoint, queda como sub-paso futuro.
Ejemplo runnable completo: examples/guide/29b-ws-binary.fitz.
Qué no está en el MVP¶
Estos items están comprometidos como deuda explícita:
- Reconnect con state replay — si el cliente se reconecta, el server hoy no replica los frames perdidos. Pattern típico de chat con history; requiere persistencia (Fase 10).
- Rooms / channels dentro de un endpoint. Hoy
broadcast()manda a TODOS los clientes del endpoint. Sub-canales (rooms de Socket.IO, topics de Phoenix) son deuda visible — modelo típico para presence, grupos, salas privadas. - Backpressure explícito. Hoy el outbox por conn es unbounded — un cliente lento puede acumular memoria. Bounded channels + drop policy es deuda residual.
Detalle completo en docs/roadmap.md para refinamientos pendientes
y planes de endurecimiento para servicios críticos.
30. Jobs sin Celery¶
En Python lo típico es Celery + Redis (o RabbitMQ): un broker
externo + un worker pool + decoradores @task + apply_async. En
Node es Bull/BullMQ con la misma forma. En Go hay cron y
goroutines pero el cron no está en el lenguaje (es lib). En
Rust hay tokio-cron-scheduler. Todos comparten el problema: una
dependencia externa por cada modo (cron, jobs en background,
queue), configuración separada, y a veces un proceso aparte.
En Fitz los jobs son parte del lenguaje con tres piezas:
@cron("expr") para tareas periódicas, @background para
marcar fns spawneables, y spawn(fn_call) para fire-and-forget
desde un handler HTTP. Sin Celery, sin Redis, sin systemd timers.
Todo corre en el mismo binario con paridad bit-a-bit fitz run ↔
fitz build.
Las piezas¶
@cron("expr") — corre la fn periódicamente según una cron
expression. La fn no admite params (los jobs no reciben input) y
retorna Null o Result<Null> (el runtime descarta el valor; usa
Result si querés loguear fallas):
@cron("0 0 * * *") // cada medianoche (5 fields Unix clásico)
fn cleanup_old_sessions() -> Null {
print("[cron] limpieza diaria")
return null
}
@cron("*/5 * * * * *") // cada 5 segundos (6 fields con seconds)
async fn heartbeat() -> Null {
return null
}
Sintaxis aceptada por el parser:
- 5 fields Unix clásico:
"min hora día mes día-semana". El runtime prependa"0 "(segundo 0) automáticamente para compatibilidad con el cron tradicional. - 6 fields con seconds al inicio:
"sec min hora día mes día-semana". Útil para sub-minuto. - 7 fields con año al final:
"sec min hora día mes día-semana año".
El checker valida shape (1 arg Str, sin params, return
Null/Result/Future, no combinable con @get/@post/@ws/
@background). La sintaxis del cron expression se valida en
runtime/codegen al registrar el job — typos disparan error claro
con un ejemplo correcto.
@background — marca una fn como autorizada para spawn(...).
Sin el decorator, el checker rechaza el callsite estáticamente:
@background
async fn send_email(to: Str, body: Str) -> Null { ... }
// En cualquier handler:
let _ = spawn(send_email("ada@example.com", "welcome"))
Es opt-in del autor para evitar usos accidentales sobre fns regulares cuyo retorno el caller espera consumir.
spawn(fn_call) — fire-and-forget. Devuelve Future<T> con
T el tipo de retorno de la fn target. Si descartás el Future
(let _ = spawn(...)), la task queda detached ejecutándose en
background. Si lo guardás y .await-eás, esperás al resultado:
// Fire-and-forget — la task corre sola.
let _ = spawn(send_email("ada", "hi"))
// O esperás al resultado (útil para coordinar múltiples jobs).
let result: Null = spawn(send_email("ada", "hi")).await
El target del spawn(...) debe ser un call literal a una fn
@background. El checker rechaza spawn(x) con x variable o
spawn(obj.method()) — el target debe ser claro estáticamente.
Ejemplo completo: URL shortener con cron + spawn¶
examples/guide/30-cron-background.fitz arma un servicio mínimo
en menos de 100 líneas que combina todo el stack de jobs:
type Link { slug, url, clicks }— dominio.let LINKS: Map<Str, Link> = {}— DB en memoria (para persistencia real, usar el ORM nativo del cap 31).@background async fn track_click(slug)— simula I/O lento consleep(100).await(típico: insert en analytics DB).@cron("*/5 * * * * *") fn stats()— imprime estadísticas cada 5 segundos.@get("/go/{slug}") fn redirect(slug)— devuelve el target URL inmediato y disparaspawn(track_click(slug))para registrar el clic en background sin bloquear la response.
Una sesión típica:
$ fitz run examples/guide/30-cron-background.fitz
🏔️ Fitz HTTP escuchando en http://127.0.0.1:43930
POST /links
GET /links
GET /go/{slug}
🕐 Fitz scheduler arrancado con 1 job(s) cron
@cron stats (*/5 * * * * *)
# Crear un link.
$ curl -X POST localhost:43930/links \
-H 'Content-Type: application/json' \
-d '{"slug":"fitz","url":"https://github.com/.../fitz"}'
{"slug":"fitz","url":"https://github.com/.../fitz","clicks":0}
# Redirigir — la response sale inmediato; el clic se registra
# async en background.
$ curl localhost:43930/go/fitz
"→ https://github.com/.../fitz"
[bg] click registrado: /fitz → 1 total # log del background
# Tras 5 segundos el cron dispara stats.
[cron] stats — 1 link(s) registrados
[cron] /fitz → 1 clicks
El ejemplo entero compila a binario nativo con fitz build y
produce output bit-a-bit idéntico al intérprete.
Persistencia, retry y timezone (iter2)¶
Cuatro kwargs opcionales endurecen @cron(...) para servicios
que tienen que sobrevivir a un restart o tolerar fallos
upstream:
tz="IANA/Name"— interpreta la cron expression en el huso indicado en vez de UTC. Acepta cualquier IANA timezone ("America/Argentina/Buenos_Aires","Europe/Madrid","UTC"). Sin esto,"0 9 * * *"significa "9 AM UTC", lo que probablemente NO sea lo que el operador espera. Default"UTC"(paridad con el MVP).retry={max: N, backoff: "...", initial_secs: I, max_secs: M}— si el handler devuelveErr(...)o paniquea, reintenta hastaNveces con delay calculado. Backoffs aceptados:"exponential"(delay =I * 2^(attempt-1), recomendado para upstream que puede bouncear),"linear"(I * attempt),"constant"(I). Todos capeados pormax_secs. Default: sin retry (un solo intento, paridad con el MVP).catch_up=true— si el proceso estuvo abajo durante uno o más ticks programados, al arrancar ejecuta UN run inmediato (noN— evita spam). Defaultfalse= skip silencioso (la semántica de "no acumular trabajo viejo" es la correcta para el 90% de casos; opt-in cuando importa).store=<binding>— persiste el registry del job y cada run en las tablasfitz_cron_jobs/fitz_cron_runsde la conn DB indicada. Sin esto, los jobs viven en memoria y se pierden al reiniciar (paridad con el MVP). Con esto, podés inspeccionar la historia conpsql:
-- Último estado de cada job.
SELECT name, schedule, tz, last_run_at, last_status, last_error
FROM fitz_cron_jobs;
-- Últimas N ejecuciones (incluye intentos de retry).
SELECT job_name, started_at, finished_at, status, attempt, error
FROM fitz_cron_runs
ORDER BY id DESC LIMIT 20;
Las tablas se crean automáticamente al boot del scheduler con
CREATE TABLE IF NOT EXISTS. Schema:
fitz_cron_jobs(
name PRIMARY KEY, schedule, tz,
last_run_at, last_status, last_error, next_run_at
)
fitz_cron_runs(
id BIGSERIAL, job_name, started_at, finished_at,
status, attempt, error
)
-- status: 'running' | 'ok' | 'failed' | 'retrying'
-- attempt: 1-indexed; retry máx = N produce hasta N+1 rows
Ejemplo end-to-end combinando los cuatro kwargs +
HTTP — examples/guide/30b-cron-persistente.fitz:
let db = db.connect(env_or("DATABASE_URL", "postgres://...")).await
@cron("*/10 * * * * *",
tz="America/Argentina/Buenos_Aires",
retry={max: 3, backoff: "exponential",
initial_secs: 1, max_secs: 30},
catch_up=true,
store=db)
async fn heartbeat() -> Result<Null> {
print("[cron] heartbeat tick")
return Ok(null)
}
@get("/health")
fn health() -> Str { return "ok" }
@server(43931, docs=false)
fn main() => 0
Detalle del binding db: queda como Result<DbConn> (no
DbConn) porque db.connect(...).await retorna Result y ?
no está soportado top-level. El runtime/codegen desempaca
automáticamente vía el trait __FitzCronStoreFrom: si la conn
falló al inicio, panea con mensaje claro citando el motivo
(equivalente al expect() que escribirías a mano).
Paridad bit-a-bit: el comportamiento de los cuatro kwargs
es idéntico en fitz run (HTTP+cron — vivo por el server) y
fitz build → binario nativo. Los tipos del runtime
(BackoffKind, RetryConfig, CronJobOptions) tienen sus
paralelos __FitzBackoffKind/__FitzRetryConfig/
__FitzCronOptions en el binario, con la misma lógica de
delay_for_attempt y invoke_with_retry.
Limitación conocida — fitz run en modo cron-only
(programa con @cron(..., store=db) y SIN @server ni
handlers HTTP) tiene un bug heredado del runtime tokio
current_thread del intérprete: la conn DB queda atada al
runtime del evaluator y el scheduler la pierde al pasar a
multi_thread. Workarounds: (a) usar fitz build (el binario
nativo arma su propio runtime multi-thread limpio), o (b)
agregar al menos un handler HTTP trivial (como el /health de
arriba) que mantiene el runtime vivo durante el desarrollo.
Cierre del bug queda como deuda separada de iter2.
Cron-only mode (sin server HTTP)¶
Un programa con solo @cron (sin @server ni handlers HTTP)
también funciona — el main queda vivo bloqueante hasta SIGINT/
Ctrl+C (modo systemd-friendly):
// cleanup.fitz — script periódico standalone.
@cron("0 3 * * *") // todos los días a las 3 AM
fn cleanup() -> Null {
print("[cron] cleanup nocturno")
return null
}
print("Daemon arrancado. Ctrl+C para salir.")
$ fitz build cleanup.fitz
$ ./cleanup
Daemon arrancado. Ctrl+C para salir.
🕐 Fitz scheduler arrancado con 1 job(s) cron
@cron cleanup (0 3 * * *)
# ... espera hasta 3 AM o Ctrl+C ...
^C
🕐 Fitz scheduler recibió Ctrl+C, terminando.
Es exactamente lo que necesitás para un service systemd:
ExecStart=/usr/local/bin/cleanup, sin scripts extra ni
intérpretes embebidos.
Por qué Fitz hace esto distinto¶
Cinco diferenciales que vuelven a Fitz único en este espacio:
- Decoradores nativos del lenguaje:
@cron/@backgroundson parte del compilador, no una lib opcional. El checker valida shape en compile-time (typos en cron expression, params prohibidos, conflictos con@get/@ws/@auth_provider); no esperás a runtime para descubrir que el job está roto. Vs Celery (lib externa con@taskreflection), Bull (decorators TypeScript con tipos opcionales), Spring@Scheduled(reflection en runtime con AOP). - Sin broker externo: Redis/RabbitMQ NO son requisito. Los jobs viven en memoria del proceso. Para 90% de los servicios reales (tareas de mantenimiento, scripts periódicos, fire-and- forget de notificaciones) eso es suficiente. Persistencia llega con Fase 10 + DB nativa — sin cambiar la sintaxis.
spawncon tipado: el checker valida en compile-time que el target tenga@backgroundY refina el ret type aFuture<T>con T concreto. Vstokio::spawnRust (sin marcador, cualquier closure pasa), vsasyncio.create_taskPython (sin tipos), vs Celeryapply_async(string-based task name, lookup en runtime).- Paridad bit-a-bit
fitz run↔fitz build: el flow de jobs corre idéntico en intérprete (rapid dev) y binario nativo (deploy a prod). Cero "anda en local pero no en server". - Cero
cargo add tokio-cron-scheduler/pip install celery: el cratecron+ helpers van en el binariofitz. Deploy es un binario.
Ningún otro lenguaje hoy combina cron + background workers + spawn tipado en el core del lenguaje, sin broker externo y con paridad intérprete↔binario.
Qué no está en el MVP¶
iter2 cerró persistencia + retry + timezone + catch_up (ver sección anterior). Lo que queda como deuda explícita:
- Visibility de jobs en panel admin nativo (UI que liste
runs pasados con filtros, estadísticas agregadas, gráficos
de tasa de error). Hoy con
store=dblos datos están enfitz_cron_jobs/fitz_cron_runs— los querés conpsqlo un dashboard externo (Grafana, Metabase). Una UI dedicada estilo Sidekiq Web podría llegar como sub-paso futuro. @backgroundcon persistencia + retry. El runtime intérprete y el codegen aceptan los kwargstzyretrysobre@backgroundpero NOstorenicatch_up(los args delspawn(...)requieren serialización JSON estable + tablafitz_bg_jobsseparada). Diferido a iter3 cuando aparezca presión real.- Coordinación entre múltiples instancias (locks distribuidos
para que un cron solo corra en un nodo). Hoy cada instancia
corre todos sus jobs — si tenés 3 réplicas detrás de un load
balancer, el
@crondispara 3 veces. Para single-instance el comportamiento es correcto. spawncon coordinación múltiple:spawn(...).awaitsolo awaitea un task; paraPromise.all([...])style hace falta agregación manual con vectores de futures.fitz runcron-only constore=db: ver la "Limitación conocida" arriba. Workaround simple: agregar un handler HTTP trivial o usarfitz build.
Detalle completo en docs/roadmap.md para refinamientos pendientes
y planes de endurecimiento adicionales.
31. Postgres + ORM nativo¶
Este capítulo cierra la promesa del proyecto de "stack web first-class del lado server": HTTP nativo (cap 17) + auth (28) + WebSockets (29) + jobs (30) + DB + ORM (este). Todo en el binario
fitz, cero deps externas para features intrínsecas.
En SQLAlchemy/Django ORM/ActiveRecord/Hibernate/Prisma la
combinación DB driver + ORM se construye sumando librerías al
proyecto: pip install sqlalchemy psycopg2, pip install
django + django.db, gem pg + activerecord, JDBC driver +
Hibernate JARs, npm install prisma + @prisma/client. Cinco
dependencias mínimo, configuración manual, decoradores
"mágicos" que se resuelven en runtime con reflection
(@Entity/@OneToMany de JPA), generación de schema separada
(Prisma exige prisma generate antes de cada build), tipado
opcional que no respeta el shape real de la tabla. Y a la hora
de compilar a binario nativo: imposible para Python/Ruby, parche
para Node (con pkg/nexe y limitaciones), funciona en Go
(pgx + gorm) pero arrastra un ORM separado del compilador.
En Fitz, DB + ORM son parte del lenguaje. El módulo db
viene con un driver Postgres puro escrito en Fitz/Rust (~2400
LoC en src/db.rs, sin link a libpq) que habla wire protocol
v3.0 + SCRAM-SHA-256 + parser de 11 OIDs core. Encima del
driver, cinco decoradores nativos (@table, @primary,
@column, @belongs_to, @has_many, @has_one) declaran el
mapping type Fitz ↔ tabla Postgres. El type checker valida
estáticamente que @primary exista, que @belongs_to apunte
a un type existente, que los métodos del QueryBuilder<Row>
preserven el tipo del row a lo largo de toda la chain. Y el
codegen produce un binario nativo que ejecuta queries SQL
constantes en compile-time (cada .where(fn(u) => u.age >
18) se traduce al fragmento "age" > $1 durante el codegen,
zero overhead runtime para construir SQL). Paridad bit-a-bit
entre fitz run y fitz build — el mismo programa corre
idéntico en intérprete y binario nativo.
Las piezas¶
Driver db — módulo built-in. Siempre disponible, sin import.
// Local dev (Postgres en Docker, sin TLS):
let db = db.connect("postgres://user:pass@host:5432/dbname?sslmode=disable").await?
// Managed PG real (Heroku, RDS, Supabase, Neon, ...) — sslmode=verify-full
// recomendado. El driver trae el Mozilla CA bundle in-binary (cero
// deps system); cubre todos los managed PG mainstream.
let db = db.connect(
"postgres://user:pass@db.proyecto.supabase.co:5432/postgres?sslmode=verify-full"
).await?
let rows = db.query("SELECT id, email FROM users WHERE active = $1", [true]).await?
// rows: List<DbRow>
let r: DbRow = rows[0]
let id: Int = r.get_int("id")?
let email: Str = r.get_str("email")?
let affected = db.exec("UPDATE users SET last_seen = NOW() WHERE id = $1", [42]).await?
// affected: Int
db.close().await?
DbRow es un row opaco con extracción tipada por columna —
r.get_int/get_str/get_float/get_bool retornan Result<T> con
error claro si la col no existe, es NULL, o el tipo PG no matchea.
En handlers HTTP también podés retornar Result<List<DbRow>> y el
codegen auto-serializa cada row a {col: val, ...} en el JSON
response (útil para queries con shape dinámico no representable como
type; ver el boilerplate api-multi-tenant Enfoque B).
URL formato estándar Postgres (postgres://[user[:pass]@]host[:port]/dbname[?params]).
sslmode=disable requerido en MVP (TLS strict viene como
sub-paso futuro). El driver mantiene un pool de conexiones
internamente con reconnect automático cuando una conn muere
(health check via Weak<DbPool> + cleanup automático).
@table("nombre") — sobre un type. Lo marca como una
tabla. Implica que el ORM puede emitir SELECT/INSERT/UPDATE/
DELETE sobre el type.
@primary — sobre un field. Debe haber exactamente uno por
type con @table. Tipo Int (auto-asignado por bigserial) o
Str (UUID generado por el cliente). El checker exige unicidad.
@column(name="snake_case_col") — sobre un field cuando el
nombre del field Fitz difiere del de la columna en la tabla
(camelCase ↔ snake_case típico):
@table("orders") type Order {
@primary id: Int = 0
@column(name="user_id") user_id_fk: Int
@column(name="created_at") created: Str // ISO 8601
}
@belongs_to("Target"), @has_many("Target"),
@has_one("Target") — sobre fields para declarar relations
cross-table. Aceptan kwargs adicionales: via="fk_column" (para
has_many/has_one), fk="custom_fk_field" (para belongs_to),
on_delete="cascade" / on_update="cascade" (Postgres FK
actions: "cascade" / "set_null" / "restrict" / "no_action"):
@table("posts") type Post {
@primary id: Int = 0
title: Str
@belongs_to("User") user_id: Int // FK real en la tabla
}
@table("users") type User {
@primary id: Int = 0
email: Str
@has_many("Post", via="user_id") posts: List<Post>
// ↑ field virtual: NO entra al SELECT/INSERT normal,
// hidrata vía .preload(...) o post.user_id(db).
}
Read methods + QueryBuilder chain¶
Type.all(db) — devuelve todas las rows como List<Type>:
Type.where(closure) -> QueryBuilder<Type> — empieza una
chain de filtros. El closure recibe un nominal del row y
devuelve un Bool. El checker valida estáticamente que el closure
referencie fields que existen en el type. El translator
DURANTE EL CODEGEN walka el AST del closure y emite SQL
parametrizado:
// `(age > 18 AND role = 'admin') OR id = 1`
let admins = User.where(fn(u) => (u.age > 18 and u.role == "admin") or u.id == 1)
.all(db).await?
Chain methods: .order_by(closure, ascending: Bool),
.limit(n), .offset(n), .group_by(closure). Todos preservan
el tipo del row:
let top = User.where(fn(u) => u.age >= 18)
.order_by(fn(u) => u.age, ascending: false)
.limit(10)
.offset(0)
.all(db).await?
Terminales: .all(db) -> Result<List<Row>>,
.first(db) -> Result<Row> (error si no hay match),
.count(db) -> Result<Int>.
Operadores soportados en .where(...): comparators (==,
!=, <, <=, >, >=), lógicos (and/or/not),
aritméticos (+/-/*///%), between(a, b) sobre fields
numéricos, vars externas al closure (lookup en el scope), y
method calls sobre columns que se mapean a SQL nativo:
let active = User.where(fn(u) => u.email.is_not_null()).all(db).await?
let bands = User.where(fn(u) => u.age.between(18, 65)).all(db).await?
let pattern = User.where(fn(u) => u.email.starts_with("ada")).all(db).await?
let one_of = User.where(fn(u) => u.id.is_in([1, 2, 3])).all(db).await?
let min = 18
let adults = User.where(fn(u) => u.age >= min).all(db).await? // var externa
Métodos sobre columns Str: .is_null(), .is_not_null(),
.is_in([...]), .like(pat), .ilike(pat), .starts_with(s),
.ends_with(s), .contains(s). Patterns con %/_ se
escapan automáticamente en .starts_with/.ends_with/
.contains. is_in([]) con lista vacía → predicado false
(no rompe el query). Caveat MVP: .is_in([...]) y los array
ops requieren el List como literal (.is_in(some_var)
falla); los items adentro de la lista pueden ser vars.
Write methods + guard .where(...) obligatorio¶
Type.insert(db, row) — inserta un row. Si el @primary
es Int con default = 0, Postgres lo auto-asigna (bigserial)
y el resultado tiene el id real:
let inserted = User.insert(db, User { id: 0, email: "ada@x.com", age: 35 }).await?
print(inserted.id) // 42 (auto-asignado por Postgres)
.update(db, changes) y .delete(db) — sobre un
QueryBuilder<Row> con .where(...) previo obligatorio.
El ORM rechaza estáticamente updates/deletes sin guard
(Type.update(db, {...}) directo sin .where(...) → error
de codegen). Esto previene el accidente clásico de "olvidé
el WHERE":
let updated_rows = User.where(fn(u) => u.id == 42)
.update(db, {"age": 36, "role": "admin"})
.await?
let deleted_rows = User.where(fn(u) => u.role == "trial" and u.age < 18)
.delete(db).await?
.update acepta tanto Map literal heterogéneo ({"key": val,
...}) como una var Map<Str, Any> (caso típico:
.update(db, body.changes) con body deserializado de JSON
request). Los values de tipos compuestos (List::text[]/::jsonb).
Type.bulk_insert(rows, db) (v0.10.27) — inserta muchas rows
en batches multi-tuple VALUES. Default batch_size=1000.
Optimizado para seeds y migraciones: 1 round-trip por batch.
Devuelve el conteo total:
let rows: List<User> = []
let mut i = 0
while (i < 5000) {
rows.push(User { id: 0, email: "u{i}@x.com", age: 20 })
i = i + 1
}
let n = User.bulk_insert(rows, db).await?
print("inserted: {n}") // 5000
Sentinel id: 0 detectado de la PRIMERA row (asume shape
uniforme — todas con o todas sin PK explícita). No emite
RETURNING * (si necesitás los IDs auto-generados de cada row,
usá .insert en loop).
Composite primary keys + @index(...) (v0.10.27)¶
Composite primary key: N @primary por type. Útil para
tablas de join (M:M):
@table("memberships") type Membership {
@primary org_id: Int = 0
@primary user_id: Int = 0
role: Str = "member"
}
fitz db diff/migrate emite PRIMARY KEY (org_id, user_id).
Insert con valores explícitos del PK tuple (no sentinel).
@index(...): decorator a nivel type que declara índices
auto-emitidos por migrations. Composite, unique, partial:
@table("posts")
@index(author_id)
@index(status, published_at)
@index(slug, unique=true)
@index(status, name="published_idx", where_=status == "published")
type Post {
@primary id: Int = 0
author_id: Int = 0
slug: Str = ""
status: Str = "draft"
published_at: Str = ""
}
Kwargs disponibles: unique=true, name="...", where_=<expr>
(partial index — el kwarg se llama where_ porque where es
reservada).
Aggregates scalar + GROUP BY¶
Sobre QueryBuilder<Row> los agregados scalar devuelven Float:
let total: Int = User.count(db).await?
let avg_age: Float = User.avg(fn(u) => u.age, db).await?
let max_id: Float = User.max(fn(u) => u.id, db).await?
let sum_logins: Float = User.where(fn(u) => u.active).sum(fn(u) => u.login_count, db).await?
(Cast ::float8 automático en avg para evitar el OID Numeric
del driver — el wire protocol se simplifica.)
.group_by(closure) devuelve un Aggregated<Row>, NO un
QueryBuilder<Row>. La diferencia: Aggregated.count(db)/
.sum(...)/etc devuelven List<Map<Str, Any>> con un row por
grupo, no un scalar:
let by_role: List<Map<Str, Any>> = User.group_by(fn(u) => u.role).count(db).await?
// by_role[0] → {"role": "admin", "count": 3}
// by_role[1] → {"role": "user", "count": 47}
El checker distingue estáticamente QueryBuilder<Row> de
Aggregated<Row> — typos de método se detectan en compile-time,
no runtime.
Relations + navigation methods¶
Después de declarar @belongs_to/@has_many/@has_one, cada
field genera un método de navegación sobre la instancia:
@table("posts") type Post {
@primary id: Int = 0
title: Str
@belongs_to("User") user_id: Int
}
@table("users") type User {
@primary id: Int = 0
email: Str
@has_many("Post", via="user_id") posts: List<Post>
}
let post = Post.where(fn(p) => p.id == 1).first(db).await?
// BelongsTo: el método se llama como el field FK.
let author: User = post.user_id(db).await?
// HasMany: el método se llama como el field virtual declarado.
let user_posts: List<Post> = author.posts(db).await?
Cada navigation hace un SELECT por call (lazy). Para evitar el clásico N+1 (1 SELECT del User + N SELECTs por cada post), Fitz tiene eager loading.
Navigation chain: cuando se llama una navigation belongs_
to/has_many SIN el db (args.is_empty()), devuelve un
QueryBuilder<Target> que se puede seguir encadenando:
// Equivale a SELECT * FROM posts WHERE user_id = author.id LIMIT 5 ORDER BY id DESC
let latest_5 = author.posts()
.order_by(fn(p) => p.id, ascending: false)
.limit(5)
.all(db).await?
Por diseño: user.posts(db) es el terminal directo,
user.posts() empieza chain. Los terminales (.all/.first/
.count/etc.) son obligatorios para ejecutar.
Eager loading: .preload(...)¶
Cierra el N+1 con dispatch estático en compile-time. El
relation name viaja como Str literal en .preload(...); el
codegen emite un match exhaustivo por type con la rama
correspondiente. Typos detectados en compile-time, no
runtime:
// 1 query batch para los users + 1 query batch para
// TODOS los posts WHERE user_id IN (id_user_1, id_user_2, ...)
let with_posts: List<User> = User.preload("posts").all(db).await?
for u in with_posts {
print("{u.email}: {len(u.posts)} posts")
// u.posts ya está hidratado — cero queries adicionales.
}
En el MVP solo HasMany. BelongsTo eager (cargar el author
de N posts en 2 queries) queda como refinamiento.
Tipos avanzados: JSONB, arrays, Map¶
JSONB — un field data: Map<Str, Any> se mapea a columna
jsonb. INSERT serializa con serde_json (preserve_order)
y cast ::jsonb. SELECT parsea el text JSON con __FitzValue
(enum tagged que preserva shape heterogéneo). Null Fitz → NULL
real (no la string "null"):
@table("events") type Event {
@primary id: Int = 0
name: Str
data: Map<Str, Any> // jsonb column
}
let e = Event.insert(db, Event { id: 0, name: "click",
data: {"page": "/home", "ts": 1700000000, "user": null}
}).await?
// SELECT round-trip preserva el shape:
let back = Event.where(fn(e) => e.id == e.id).first(db).await?
print(back.data["page"]) // "/home"
Arrays Postgres — List<scalar> ↔ T[]:
List<Int>/Str/Float/Bool se mapean a int8[]/text[]/
float8[]/bool[] respectivamente. INSERT/UPDATE emiten el
cast apropiado (::int8[]/etc.). SELECT round-trip preserva
el orden:
@table("posts") type Post {
@primary id: Int = 0
title: Str
tags: List<Str> // text[] column
scores: List<Int> // int8[] column
}
let p = Post.insert(db, Post { id: 0, title: "Hola",
tags: ["rust", "postgres"], scores: [10, 20, 30]
}).await?
print(p.tags[0]) // "rust"
NULL en arrays: List<Int?> ↔ int8[] con elementos
nullable. El text format Postgres {a,NULL,c} se parsea/encodea
simétricamente:
@table("readings") type Reading {
@primary id: Int = 0
samples: List<Int?> // int8[] con NULL aceptable
}
MapMap<Str, Any> cuando
todos los values son del mismo tipo primitivo (Int/Float/Str/
Bool). El marshaling es directo (HashMap
@table("metrics") type MetricSnapshot {
@primary id: Int = 0
counters: Map<Str, Int> // jsonb con shape homogéneo
}
K se restringe a Str (Postgres jsonb keys son strings). Map<Int,
Int> → error claro.
Array ops en .where(...): operadores Postgres sobre arrays
se mapean a method calls Fitz:
// "rust" = ANY(tags)
let rusty = Post.where(fn(p) => p.tags.has("rust")).all(db).await?
// tags @> ARRAY['rust', 'postgres']
let both = Post.where(fn(p) => p.tags.contains_all(["rust", "postgres"])).all(db).await?
// scores <@ ARRAY[1, 2, ..., 100]
let small = Post.where(fn(p) => p.scores.contained_in([1, 2, 3, 4, 5])).all(db).await?
Escape hatch: db.query/db.exec crudo¶
Cuando el ORM no alcanza (CTEs complejas, window functions,
COPY, extensiones específicas como pgvector, JSON operators
->/->>/@>), el driver crudo sigue disponible:
let rows = db.query("
WITH ranked AS (
SELECT id, name, ROW_NUMBER() OVER (PARTITION BY group_id ORDER BY score DESC) AS rn
FROM items
)
SELECT * FROM ranked WHERE rn = 1
", []).await?
// rows: List<DbRow>
let affected = db.exec("UPDATE counters SET value = value + 1 WHERE key = $1", ["clicks"]).await?
Las dos APIs coexisten — usá el ORM para el 90% del código de todos los días, bajá a SQL crudo para los casos especiales.
Ejemplos del cap¶
Dos ejemplos en examples/guide/ cubren los dos casos
canónicos:
31-orm.fitz(~100 LoC, pedagógico) — muestra el shape canónico del ORM end-to-end:@tablecon todos los decoradores, insert, where + first, chainorder_by/limit/offset, operadores extendidos (starts_with/is_in/between), aggregates scalar (count/avg), GROUP BY conAggregated<Row>, navigation belongs_to/has_many, eager loading con.preload, y update/delete con guard.fitz buildproduce binario que NO requiere Postgres real al compilar; elconnectruntime falla conErrclara si la URL es inválida.31b-orm-crud-http.fitz(CRUD HTTP real end-to-end) — combina todo el stack Fitz: typesUser/Postcon decoradores ORM completos (@table/@primary/@belongs_to/@has_many), HTTP nativo (@get/@post/@put/@delete- path params), body deserialization a types custom dedicados
(
UserInput/PostInputseparan el shape DB del shape HTTP entrada),Result<T>con?propagando errores ORM hasta el cliente,env_or(...)para leerDATABASE_URLcon default, y@server(port). Endpoints: list/get/create/update/delete sobre users, relation queries (posts por user), eager loading con.preload(...)cerrando N+1, aggregate scalar, GROUP BY aggregate (/stats/by-emailconUser.group_by(...).count(db)) serializado a JSON. Requiere Postgres real para correr — el setup pre-condición está documentado al inicio del archivo (createdb + CREATE TABLE). Compila confitz buildaunque no haya Postgres local — el codegen del ORM se valida en compile-time.
Por qué Fitz hace esto distinto¶
- DB nativa, no librería: el driver Postgres (~2400 LoC en
src/db.rs) y el ORM viven en el binariofitz. Ceropip install psycopg2, cerogem install pg, cerocargo add tokio-postgres, ceronpm install pg. Cuando hacésfitz buildel binario nativo embebe el driver — un.exe/ELF/Mach-O standalone que habla wire protocol v3.0 + SCRAM-SHA-256 sin link a libpq. - SQL constante en codegen-time: cada
.where(closure)se walka del AST DURANTE EL CODEGEN, el fragmento SQL queda hard-coded en el binario. Zero overhead runtime para construir SQL. Comparable a Diesel/sqlx, mejor que SQLAlchemy/ActiveRecord que construyen SQL via objetos en runtime cada vez. - Paridad bit-a-bit
fitz run↔fitz build: lo que ves funcionar en el intérprete (rapid feedback) funciona idéntico en el binario nativo (deploy a prod). Cero "anda en local pero no en server". 16 tests E2E de paridad codegen corren contrapostgres:16en cada push amain. - Decorators del lenguaje:
@table/@primary/@column/@belongs_to/@has_many/@has_oneson parte del compilador (lexer + parser + type checker + codegen), no anotaciones procesadas por una lib opcional. El checker exige@primaryúnico, valida que@belongs_to("X")apunte a un type existente, infiere los signatures de los navigation methods. Spring@Entity/JPA + Hibernate resuelven esto en runtime con reflection — Fitz lo hace en compile-time. - Eager loading con dispatch estático:
.preload("posts")con el relation name como Str literal en compile-time produce un match exhaustivo emitido por el codegen. Typos (.preload("post")sin la "s" final) detectados en compile-time, no runtime. Comparable a Diesel'sbelonging_ tomacros, mejor que SQLAlchemyjoinedload(User.posts)donde el typo recién aparece comoAttributeErroral evaluar. - Integrado con el resto del lenguaje: tipos custom +
Result<T>+?+match+ decoradores apilables (@authenticated+@get+ handler que llamaType.where(...) .all(db).await?) + middleware/CORS + body deserialization. El ORM no es una "isla" con sus propias reglas, encaja exactamente con HTTP nativo + auth + jobs + WebSockets.
Ningún otro lenguaje moderno combina driver Postgres puro
+ ORM declarativo sobre type + paridad bit-a-bit
intérprete↔binario nativo + LSP completo (autocomplete del
ORM end-to-end con tipos refinados en cada chain method) sin
macros derive ni introspection runtime. Diesel cubre ⅘
pero requiere derives + runs Rust crudo. SQLAlchemy/ActiveRecord
cubren ORM ergonómico pero pagan reflection runtime. Prisma
genera schemas separados que viven aparte del lenguaje.
Qué no está en el MVP¶
Items comprometidos como deuda explícita:
- Migraciones automáticas (
fitz db diff/fitz db migrate): hoy el user crea las tablas condb.exec( "CREATE TABLE ...")al boot o conpsqlaparte. Las migraciones autogeneradas a partir del diff entre el shape declarado entypey el real en Postgres llegan como sub-paso futuro. - Transactions (
BEGIN/COMMIT/ROLLBACK): cada query corre en auto-commit. Bloques transaccionales condb.transaction(fn(tx) => ...)llegan como sub-paso separado. - Composite primary keys: un
@primaryúnico portype. Tables conPRIMARY KEY (a, b)requieren refactor del checker — refinable si aparece presión. - TLS strict (
sslmode=require): el driver soportasslmode=disable. TLS llega como sub-paso separado (StartTLS - cert validation).
Date / Time / UUID nativos como tipos del lenguaje— CERRADO v0.10.24 (intérprete) + v0.10.26 (codegen) + v0.10.30 (API completion Tier B).Date/DateTime/Uuidson tipos built-in con constructors (Date.today/tomorrow/yesterday/parse/ from_ymd?,DateTime.now/epoch/parse/from_timestamp?,Uuid.v4/v7/nil/parse?), aritmética (.add_days/months/years+.subtract_*), diff (.diff_days/seconds/minutes/hours), comparison nativa (</>/<=/>=) y timezone display (.to_local()/.in_tz(iana)). Round-trip Postgres automático.- JSON operators avanzados (
@@/||): los siete operadores mapeados como method calls cubren?/?&/?|/@>/->>+#>/#>>con cast tipado (has_path/path_int/path_text/path_float/path_bool, v0.10.29). Los faltantes (text search@@, concat||) se bajan adb.query(...)crudo.
Ver DB y ORM sección 28 para el listado completo de limitaciones y refinamientos pendientes.
Guía exhaustiva¶
Este capítulo es el resumen del stack DB + ORM. Para la referencia completa con todos los operadores, recetas (paginación, búsqueda, auth + ORM, cron jobs), CLI integration y limitaciones detalladas, ver el documento dedicado DB y ORM (separado de la guía porque el ORM es un dominio aparte del lenguaje base — ~2500 LoC).
Cierre¶
Este capítulo cierra el bloque "stack web first-class" del
lado server: HTTP nativo (cap 17), middleware + CORS (17b),
docs automáticas (18), async (19), build (20), interop Python
(21), auth (28), WebSockets (29), jobs (30), y DB + ORM (31).
Todo en el binario fitz, todo con paridad bit-a-bit
intérprete↔binario, todo validado en CI multi-plataforma con
Postgres real en cada push.
Lo que queda es Fase 11+ — frontend, deployment ciudadano de
primera, CLI builder — la apuesta a largo plazo. Pero el
stack server completo ya está vivo. Si tu objetivo es
"escribir una API tipada con auth + DB + jobs + WebSockets que
deploye como un binario standalone": Fitz lo hace hoy, en un
solo lenguaje, con cero requirements.txt/Cargo.toml/
package.json que mantener.
Transactions (v0.10.14)¶
Cuando un handler hace varias escrituras que deben ser
atómicas (ej: transferí dinero de una cuenta a otra, o creá
un Order + sus OrderItems en una sola operación), envolvelas
en db.transaction(fn(tx) -> Result<T> { ... }):
async fn transfer(db: DbConn, body: TransferInput) -> Result<Int> {
return db.transaction(fn(tx) -> Result<Int> {
// Las queries adentro del callback usan `tx` (mismo tipo
// DbConn que `db`, pero pegado a la misma conn física).
// Todos los métodos del ORM funcionan sin cambios.
let _ = Account
.where(fn(a) => a.id == body.from_id)
.update(tx, { "balance": Account.balance - body.amount })
.await?
let _ = Account
.where(fn(a) => a.id == body.to_id)
.update(tx, { "balance": Account.balance + body.amount })
.await?
return Ok(body.amount)
}).await
}
Garantías:
- Atomicidad — el callback ejecuta dentro de
BEGIN/COMMIT/ROLLBACK. O todas las queries persisten (COMMIT al retornarOk), o ninguna persiste (ROLLBACK automático al retornarErro si la closure paniquea). Imposible quedarse a mitad de camino. - Aislamiento — todas las queries adentro del callback usan
la misma conexión física (no acquire concurrentes del
pool). Postgres garantiza el isolation level del server
(default
READ COMMITTED). - Cleanup automático — la conn vuelve al pool al final de la tx (sea OK o Err). Sin leaks.
Diferencias clave vs otros ORMs:
- SQLAlchemy usa session+commit explícitos. Tenés que llamar
session.commit()osession.rollback()a mano. Si el handler retorna antes de commit, la tx queda colgada server-side hasta que la conn se cierre. Fácil de olvidarse. - Diesel usa
conn.transaction(|tx| {...})closure-based similar a Fitz — patrón validado por décadas en Rust ecosystem. - Prisma
prisma.$transaction([...])toma un array de operaciones declarativas; menos flexible que callbacks.
Sintaxis — desde v0.10.15 el callback puede ser FnExpr
inline (async fn(tx) -> Result<T> { ... }) o fn nombrada
(declarada con async fn foo(tx: DbConn) -> Result<T> { ... } y
pasada por ident). Ambas formas funcionan tanto en fitz run
como en fitz build (paridad bit-a-bit). El inline es la forma
recomendada para closures cortos; nombrada cuando reusás la misma
lógica de tx en varios endpoints.
Captures del outer scope (vars del handler que envuelve la tx)
funcionan vía doble move Rust emit (outer + async move) — el
body puede usar body.field, user.id, args del handler, etc.,
sin tener que pasarlos explícitamente.
Lo que NO soporta el MVP (deuda futura):
- Nested transactions (SAVEPOINT). Una
db.transaction(...)adentro de otra es no-op semántico — el inner BEGIN se trata como savepoint en Postgres pero Fitz lo emite como BEGIN plano (que Postgres ignora con warning). - Niveles de aislamiento custom (
READ COMMITTED,SERIALIZABLE, etc.) — usa el default del server. - Transacciones read-only (
BEGIN READ ONLY) — el callback siempre puede escribir.
Migraciones automáticas (v0.10.16)¶
Hasta v0.10.15 el schema vivía en db.exec("CREATE TABLE IF NOT
EXISTS ...", []) al boot del programa (ver examples/guide/
31b-orm-crud-http.fitz). Funciona pero es manual y no versionado.
Desde v0.10.16, Fitz tiene un subcomando dedicado:
# 1. Edités `@table type User { ... name: Str = "" }` agregando un field.
# 2. Generás migration vacía.
fitz db new add_name_to_users
# → migrations/20260529150000_add_name_to_users.sql
# 3. Generás el SQL automático y lo redirigís al archivo.
fitz db diff > migrations/20260529150000_add_name_to_users.sql
# 4. Aplicás contra la DB (tracking idempotente en `_fitz_migrations`).
fitz db migrate
# 5. Estado en cualquier momento.
fitz db status
# 6. Revertir el último (v0.10.17 — requiere sección `-- DOWN` en el .sql).
fitz db rollback
fitz db diff introspecciona el schema real de Postgres
(information_schema + pg_catalog), lo compara con los @table
type declarados, y emite el ALTER TABLE / CREATE TABLE /
CREATE INDEX necesario para sincronizarlos. El comparador es
determinístico (orden seguro de aplicación) e idempotente
(diff(target, target) == "").
Defaults SQL — desde v0.10.16, @db_default("NOW()") (o
cualquier expresión SQL Postgres válida) hace que el diff emita
DEFAULT NOW() en el CREATE TABLE / ADD COLUMN automáticamente.
@db_default sin args sigue siendo marker-only (skipea INSERT,
sin default específico). El diff normaliza
(NOW() ↔ now(), casts 'foo'::text ↔ 'foo') para evitar
falsos positivos cuando Postgres reporta el default en su formato
canónico.
Rollback + renames seguros (v0.10.17): las migrations soportan
secciones -- UP / -- DOWN; fitz db rollback [--count N]
revierte las últimas N aplicadas ejecutando su DOWN adentro de tx.
Renames Fitz-side sin perder datos se marcan con el decorator
transient @renamed_from("old_name") sobre el field o el type;
el diff emite ALTER TABLE ... RENAME COLUMN/TABLE en vez de
DROP + ADD.
Drift check + stamping (v0.10.18): fitz db check corre el
diff y devuelve exit 0/1 según haya drift entre el schema declarado
y la DB real — hook para CI bloqueante ("no merge si el schema
diverge"). fitz db stamp <version> (o --all) marca una
migration como aplicada sin ejecutar el SQL — útil para
adoptar Fitz en una DB legacy donde el schema ya está aplicado
manualmente.
Data migrations en .fitz (v0.10.19): el dir migrations/
acepta tanto .sql (DDL/DML crudo) como .fitz (scripts del
propio lenguaje). Para transforms con lógica que SQL crudo no
expresa elegantemente (back-fills condicionales, parseo de JSON
viejo, HTTP calls durante la migración, etc.), declarás
async fn migrate(db: DbConn) -> Result<Null> en un .fitz y
el runner lo invoca con db pre-bindeado. Opcionalmente
async fn rollback(db: DbConn) para fitz db rollback. Se
intercalan con .sql en orden cronológico del prefix timestamp.
History + offline SQL + squash (v0.10.20): fitz db history
lista las migrations applied (audit log con applied_at).
fitz db migrate --sql emite el SQL pendiente al stdout para
handoff a un DBA (en lugar de ejecutar). fitz db squash
<from> <to> combina N migrations viejas en una sola
(concatena UP en orden + DOWN en orden inverso, mueve files
originales a migrations/squashed/, actualiza tracking) —
útil para acelerar bootstrap de devs nuevos en repos con 100+
migrations.
Schemas custom (v0.10.21): @table("schema.name") mapea
una tabla a un schema Postgres no-public (multi-tenant via
schemas, separación dev/test, módulos aislados, etc.). El diff
emite CREATE SCHEMA IF NOT EXISTS automático antes del
CREATE TABLE. El ORM nativo (SELECT/INSERT/UPDATE/DELETE) usa
el qualified name "schema"."name" en TODAS las queries. Sin
. en el arg de @table, schema = public (compat con
código pre-v0.10.21). Cierra la Fase 10.6 — fitz db ...
paquete completo equivalente a Alembic.
Limitaciones del MVP: ALTER COLUMN ... TYPE sin USING (cambios
de tipo incompatibles fallan — editá la migration para agregar
USING (col::int)); solo schema public. Detalle completo +
workflow avanzado en docs/db-orm.md sección 26.c.
Cero deps externas: ni Alembic ni Flyway ni Liquibase ni
TypeORM CLI. Todo vive en el binario fitz. La fuente de verdad
es tu código tipado, no un YAML aparte ni reflection runtime.
Tier S — observabilidad + introspect (v0.10.28):
fitz db inspect— introspect del schema real de la DB (no de tu código), opcionalmente filtrado por--schemao--table, con output texto plano o--jsonmachine-readable. Útil para auditar antes de migrar, descubrir tablas legacy, o comparar dev vs prod. v0.10.29 suma--all-schemaspara listar TODOS los schemas user-defined a la vez. Detalle endocs/db-orm.mdsec 29.@index(col, using="gin"|"gist"|"brin"|"hash"|"spgist")— method override para full-text search (ginsobre tsvector), range queries (gist), large tables resumidas (brin), etc. sin bajar adb.exec. Defaultbtree(Postgres default).FITZ_DB_LOG=1|verbose— env var opt-in que loguea cada query del driver a stderr. v0.10.29 suma redaction automática de secrets (password/secret/token/api_key/ etc. quedan como<redacted>). Cubierto en cap 32.
Cierre masivo de v0.10.29 — ORM completo:
- JSON path operators —
e.data.has_path([...]),e.data.path_int([...]),e.data.path_text([...]),e.data.path_float([...]),e.data.path_bool([...])para acceso anidado con cast tipado vía#>/#>>Postgres. Cierra el agujero del.get("k")single-level. @@full-text search —body_tsv.matches(query)conto_tsquerypara syntax avanzada oplainto_matches(input)para search bars libres.@unique(col1, col2, ...)— composite uniqueness shortcut ergonómico (alias de@index(unique=true)).@check_constraint("expr")— emiteCHECK (<expr>)en CREATE TABLE para constraints declarativos.- Cross-schema FK transparente —
@belongs_to("User")con target en otro schema (vía@table("public.User")) emiteREFERENCES "public"."users"(id)automáticamente. - Diff completo de indexes — el migrator detecta cambios en
using/where_clause/unique/columnscuando los nombres matchean, emitiendoDROP + CREATEpara regenerar. - DB errors con SQL + SQLSTATE + params — el
DbError::ServerDisplay ahora incluye[<SQLSTATE>]: <msg>+[sql: <query> params=[...]](con redaction). FITZ_DB_MAX_CONNS— env var opt-in para overridear el pool size (default 10, clamp[1, 200]).
Detalle completo en docs/db-orm.md.
32. Variables de entorno¶
Para hablar con secrets, ports configurables y rutas dependientes del deploy, Fitz tiene 3 builtins dedicados desde la mini-fase env (2026-05-22):
env(key: Str) -> Result<Str>— lee una variable de entorno. Si existe, devuelveOk(value); si no,Err("env var X no definida"). Forzás manejo con?omatch(paralelo afind/get/json.loads).env_or(key: Str, default: Str) -> Str— la misma operación con valor por default. Nunca falla. Útil para config opcional (let port = env_or("PORT", "3000")).load_env(path: Str) -> Result<Null>— parser KEY=VALUE simple que setea las vars víastd::env::set_var. Sin auto-load al boot (por diseño: explicit > magic). Llamalo en elmainantes de arrancar el server.
Patrón canónico — secrets y config¶
@server(3000, "0.0.0.0")
fn main() => 0
// Cargar .env si está en el cwd, ignorar si falta (modo dev).
match load_env(".env") {
Ok(_) => print("[boot] .env cargado"),
Err(_) => print("[boot] sin .env, leyendo del environment del proceso"),
}
let JWT_SECRET = env_or("JWT_SECRET", "demo-cambiame-en-prod")
let DB_HOST = env_or("DB_HOST", "localhost")
let LOG_LEVEL = env_or("LOG_LEVEL", "info")
// Secret obligatorio sin default razonable — fail-fast al boot.
fn load_session_key() -> Result<Str> {
let key = env("SESSION_KEY")?
return Ok(key)
}
Nota: hoy el decorator @server(port, host) recibe port como
literal Int. Si necesitás puerto configurable, parsealo en el
boot via builtin externo o capturalo desde la guía de tu app. Una
mini-fase futura podría sumar env_int(key, default) -> Int para
zafar de la conversión manual.
Formato del archivo .env¶
# Comentarios con `#` ignorados
APP_PORT=3000
JWT_SECRET=super-secret-32-chars-min
GREETING="con espacios entre comillas"
# Líneas vacías ignoradas también
DATABASE_URL=postgresql://user:pass@localhost:5432/db
Reglas del parser:
- Líneas vacías y las que empiezan con # se ignoran.
- Split por el PRIMER = (los = adicionales en el valor pasan
literal).
- Comillas dobles wrapping (KEY="value") se strippean.
- Sin variable expansion ($VAR/${VAR}), sin multi-line, sin
escape chars. Si entra demanda, mini-fase futura dedicada.
Por qué Result<Str>, no Str con panic¶
El modelo "sin excepciones" del lenguaje exige que cada operación
que puede fallar lo declare en el tipo. env("KEY") puede fallar
(la var no existe), entonces retorna Result<Str> — el caller
tiene que manejar el caso, ya sea con ? (propagar) o match
(rama explícita). Esto evita el clásico bug de "olvidé exportar la
variable y el server crashea con un mensaje opaco".
Si querés el modelo "siempre devuelve algo", usá env_or(key, default):
// env_or nunca falla — perfecto para defaults razonables.
let log_level = env_or("LOG_LEVEL", "info")
load_env no auto-carga al boot¶
A diferencia de Rails/FastAPI/Express, Fitz NO carga .env
automáticamente. El usuario explícitamente llama
load_env(".env")? (o el path que prefiera) al boot. Razón: el
comportamiento implícito de auto-loading es la fuente típica de bugs
"funciona en mi máquina y no en CI" (porque CI no tiene .env
y producción tampoco). Hacerlo explícito hace visible qué se carga
y desde dónde.
Si querés el comportamiento auto-load, es 1 línea:
let _ = load_env(".env")
// Ignoramos el Result porque si no existe el .env tampoco es error
// (las vars vienen del environment del proceso).
Errores con mensajes claros¶
Cuando env(key) falla, el mensaje cita la key:
Cuando load_env(path) falla, el mensaje cita el archivo + el
problema específico:
no se pudo leer `/config/.env`: No such file or directory (os error 2)
/config/.env:7: línea sin `=` — formato esperado `KEY=VALUE`
/config/.env:12: key vacía antes del `=`
Test de unit usando env vars¶
Para escribir tests que dependan de env vars, setealas en el test
mismo con std::env::set_var desde Rust (o desde el script que
lanza el test runner). El builtin env() lee del environment del
proceso, que es lo que cualquier herramienta de testing modifica.
Observabilidad — FITZ_DB_LOG y FITZ_HTTP_LOG (v0.10.28)¶
Dos env vars opt-in para logging del runtime. Zero overhead si no están seteadas (default silencioso).
FITZ_DB_LOG — loguea cada query del driver Postgres:
export FITZ_DB_LOG=1 # simple: SQL + tiempo
export FITZ_DB_LOG=verbose # además params (truncados a 80 chars)
[fitz-db 1.2ms] SELECT id, email FROM users WHERE id = $1
[fitz-db 4.1ms verbose] INSERT INTO users (name) VALUES ($1) params=[$1="ada"]
FITZ_HTTP_LOG — access log per-request paralelo a uvicorn /
nginx access log:
export FITZ_HTTP_LOG=1 # simple: method + path + status + tiempo
export FITZ_HTTP_LOG=verbose # además User-Agent + Content-Length
[fitz HTTP 12.3ms] GET /users/42 → 200
[fitz HTTP 4.1ms] POST /users → 201
[fitz HTTP 45.2ms verbose] GET /users → 200 (UA="curl/8.0" len=1234)
Ambas:
- Salen a stderr — no contaminan el output del programa.
- Loguean tanto en
fitz runcomo en el binario compilado porfitz build(paridad bit-a-bit, hereda gratis del crate compartido). - Cubren todo el tráfico real: HTTP loguea handlers + preflight
OPTIONS + rutas auto
/openapi.json//docs; DB loguea SELECT/INSERT/UPDATE/DELETE/DDL + queries internas del ORM. - Mode se fija al primer acceso del proceso (LazyLock). Cambios mid-run de la env var NO se reflejan.
v0.10.29 — redaction de secrets en FITZ_DB_LOG=verbose: los
params correspondientes a campos sensibles (password/secret/
token/api_key/auth_token/access_token/refresh_token/
private_key/credential/passphrase/session_*/csrf_token)
se enmascaran automáticamente como <redacted> en el output.
Heurística best-effort que mira ~50 chars antes del placeholder
+ descarta matches separados por WHERE/AND/OR/etc. Sobre-
redacta en bordes ambiguos (INSERT con varias columnas) por
seguridad — NO sustituye una review general antes de prod, pero
cierra el agujero más obvio.
Pool tuning — FITZ_DB_MAX_CONNS (v0.10.29)¶
export FITZ_DB_MAX_CONNS=50 # apps con mucho concurrent HTTP load
export FITZ_DB_MAX_CONNS=3 # cron / batch con poco load (default 10)
Override del pool size del driver Postgres. Clamp [1, 200],
fallback default 10 ante valores inválidos. Aplica global al
proceso. Se fija al primer acceso (LazyLock); reiniciá para
cambiar.
33. Observability — logs, spans, métricas, OTel¶
Hito de Fase 12.3 (v0.12.0) — Fitz tiene observability ciudadana de primera clase en el core del compilador. Tres piezas nativas sobre el stack web first-class:
- Structured logging built-in —
log.info/warn/error/debug(...)con kwargs heterogéneos, output JSON o pretty, filter via env var, redacción automática deSecret. - Spans HTTP automáticos — cada request abre un
SpanContextroot contrace_id/span_idOTel-compatibles; logs adentro heredan automático el contexto. Counter + Histogram con labels para correlación cross-metric. - Bridge OpenTelemetry opcional — cuando
OTEL_EXPORTER_OTLP_ENDPOINTestá seteada, spans y logs van al backend (Jaeger/Tempo/Honeycomb/Datadog). Métricas vía endpoint/metricsPrometheus scrape (Tier3).
Paridad bit-a-bit fitz run ↔ fitz build para los tres
bloques. Activación dual (compile-time + env var). Decoradores
nativos del lenguaje, no librerías opt-in.
Panorama vecino¶
| Lenguaje | Logs estructurados | Spans HTTP auto | OTel built-in |
|---|---|---|---|
| Python (FastAPI) | structlog/loguru (lib) |
Manual con opentelemetry-api (lib) |
opentelemetry-instrumentation-* (lib) |
| TypeScript (Express) | pino/winston (lib) |
Manual con @opentelemetry/* (lib) |
@opentelemetry/sdk-node (lib) |
| Go (chi/gin) | slog (std lib) o zap (lib) |
Manual con go.opentelemetry.io/contrib (lib) |
Lib oficial |
| Rust (axum) | tracing (lib) |
Manual con tower-http + tracing-opentelemetry (lib) |
Lib oficial |
| Fitz | Core compilador | Core compilador (auto) | Core compilador (env var → on) |
En FastAPI/Express/Go/Rust, sumar observability completa requiere
~3-5 libs distintas, glue manual, y conocer la matriz de
compatibilidad entre versiones (típica fricción en producción).
En Fitz, observability viene activada de fábrica con HTTP:
escribís @get("/users") y ya tenés span, log estructurado de
access, Counter + Histogram, todo correlacionado con trace_id.
33.1. log.info/warn/error/debug — structured logging¶
log es un módulo built-in del lenguaje (no requiere import).
Cuatro métodos correspondientes a los niveles syslog clásicos:
log.info("Server arrancado", port: 3000, env: "prod")
log.warn("Cache miss alta", hit_rate: 0.42)
log.error("DB query falló", error: "timeout", duration_ms: 5234)
log.debug("Loop iter", i: 42, current: "x")
Output: JSON estructurado a stderr por default.
{"timestamp":"2026-06-03T15:23:45.678Z","level":"INFO","msg":"Server arrancado","port":3000,"env":"prod"}
Una línea por log, ordering estable de fields (timestamp →
level → msg → kwargs en orden de declaración). Listo para
ingest por Loki/Datadog/Splunk/etc.
Pretty mode con colors ANSI cuando stderr es TTY o cuando
seteás FITZ_LOG_FORMAT=pretty:
Filtros por nivel via env var FITZ_LOG (default info):
FITZ_LOG=debug ./mi-app # incluye debug, info, warn, error
FITZ_LOG=warn ./mi-app # solo warn + error
Soporta targets selectivos: FITZ_LOG="fitz::log=info,h2=warn"
para filtrar logs por crate (formato tracing-subscriber).
Kwargs heterogéneos: Int, Float, Str, Bool, Null,
Secret<T> (redactado automático), List<T>, Map<K, V>,
nominal types (Display de la instance).
let user = User { id: 7, name: "ada" }
let api_key = secret("KEY")?
log.info("Login", user: user, key: api_key, tags: ["admin"])
// → {"msg":"Login","user":"User { id: 7, name: \"ada\" }","key":"***","tags":["admin"]}
Reservadas: level, msg, timestamp, trace_id, span_id.
Usarlas como kwarg → error de compilación.
33.2. Spans HTTP automáticos + correlación¶
Cada request HTTP arranca un SpanContext root con:
- trace_id — 32 hex chars (16 bytes), OTel-compatible.
- span_id — 16 hex chars (8 bytes).
Todos los log.X(...) adentro del handler (incluso adentro de
fn llamadas, async tasks, etc.) heredan automático el contexto.
No tenés que pasar el trace_id por parámetro — vive en un
task_local de tokio que cruza las await boundaries.
fn parse_body(json: Str) -> Result<User> {
log.debug("Parsing body", len: json.len()) // ← trace_id auto
return Ok(User { id: 1, name: "x" })
}
@post("/users")
async fn create_user(body: User) -> Result<User> {
log.info("Request recibida", id: body.id) // ← mismo trace_id
return Ok(body)
}
Access log automático en cada request. Sin código extra emitís:
{"timestamp":"...","level":"INFO","msg":"http.access","trace_id":"...","span_id":"...","http.method":"POST","http.target":"/users","http.status_code":201,"duration_ms":42}
Naming OTel-compatible (http.method/http.target/
http.status_code). El http.target usa el path template
(/users/{id}) NO el path resuelto (/users/42) — evita
cardinality explosion en herramientas downstream.
Métricas built-in:
- Counter http_requests_total{method, path, status} — total de
requests por endpoint + verbo + status.
- Histogram http_request_duration_seconds{method, path, status}
— distribución de latencia.
Labels EXACTAMENTE iguales entre Counter y Histogram para correlación cross-metric en Grafana/etc.
Opt-out por servidor entero con @server(observability=false)
— bypass total del wrapper de instrumentación. Útil en hot-paths
o cuando tenés tu propio sistema de observability custom.
33.3. OTel exporter — backends reales¶
Cuando seteás la env var OTEL_EXPORTER_OTLP_ENDPOINT, Fitz
instala automáticamente:
- TracerProvider que exporta cada span HTTP a /v1/traces
via OTLP HTTP/proto.
- LoggerProvider que exporta cada log.X(...) LogRecord a
/v1/logs con trace_id/span_id propagados.
Compatible con cualquier OpenTelemetry collector + backends spec-conformes: Jaeger, Tempo (Grafana stack), Honeycomb, Datadog, New Relic, Lightstep, Dynatrace, SigNoz, Aspecto, Aspecto, Uptrace, etc.
Otras env vars OTel-standard que Fitz lee:
- OTEL_SERVICE_NAME — nombre del servicio en el backend
(default "fitz-app").
- OTEL_TRACES_SAMPLER_ARG — ratio de sampling Float [0.0, 1.0]
(default 1.0 = todo).
Sin la env var del endpoint, exporter no se instala — zero overhead, cero conexiones de red. Los logs siguen yendo a stderr; el access log y métricas Counter/Histogram funcionan igual (in-memory).
Correlación logs↔spans en el backend: cuando OTel está
activo, el trace_id que ves en los logs stderr/Loki es el
mismo que muestra Jaeger/Tempo. Querys cross-pipeline tipo
"todos los logs del request abc123" funcionan sin glue.
33.4. /metrics Prometheus opt-in¶
Para users que prefieren Prometheus scrape sobre OTLP push,
activá el endpoint con el kwarg prometheus=true sobre el
@server:
Cambio en v0.13.1: el path env var
FITZ_PROMETHEUS=1como override en runtime ya no funciona. El binario tiene que declarar@server(prometheus=true)literal en código — el codegen revisa el AST en compile-time para decidir si linkear o nometrics-exporter-prometheus. La razón: la dep + sus transitivos (prometheus,indexmap,protobuf) sumaban ~5 min al smokecompile_e2een CI Linux porque se linkeaba en cualquier programa con HTTP, no solo en los que exportan Prometheus. Trade-off aceptado: production deployments declaran Prometheus en código (es la convención normal de Kubernetes + scrape configs); el env var override era nice-to -have que no justificaba el costo de CI. Verdocs/deudas-post-5b.md→ "Smoke compile_e2e — gating de deps emitidas" para el detalle técnico.
Cuando activo, Fitz instala PrometheusBuilder como recorder
global del crate metrics. Los Counter/Histogram que YA emite
el wrapper HTTP empiezan a popular Prometheus automático. Y
auto-mounta GET /metrics con exposition format:
# HELP http_requests_total
# TYPE http_requests_total counter
http_requests_total{method="POST",path="/users",status="201"} 42
# HELP http_request_duration_seconds
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{...,le="0.005"} 10
http_request_duration_seconds_bucket{...,le="0.01"} 25
...
El endpoint vive en el mismo puerto + transporte que el
resto de la app (NO un puerto separado). Si declaraste tu
propio @get("/metrics"), el tuyo gana — mismo patrón que
/openapi.json / /healthz.
Prometheus + OTel: si los dos están activos, Prometheus
gana (solo UN recorder global de metrics permitido). El
OTel exporter de spans/logs sigue funcionando; solo las métricas
van a Prometheus en vez de OTLP.
33.5. @trace y @metric — instrumentación manual (Fase 12.7)¶
La auto-instrumentation HTTP de Fase 12.3 cubre cada request con span + access log + métricas, sin tocar nada. Para funciones business logic que querés medir o nombrar como spans dedicados, Fitz tiene dos decoradores sobre fns user:
@trace(name="risk_score")
@metric(name="risk")
fn evaluar_riesgo(perfil: Perfil) -> Float {
return perfil.score * 1.5
}
@trace(name="X")abre untracing::info_span!("X")que envuelve cada call. Logs emitidos adentro de la fn (conlog.info(...)) heredan automáticamente eltrace_id/span_iddel span padre.@metric(name="X")registra dos métricas por call:X_duration_seconds(histogram) — distribución de la duración.X_calls_total(counter) — total de invocaciones.
Sin el kwarg name= se usa el nombre de la fn. Son apilables —
podés tener @trace + @metric sobre la misma fn. El kwarg
name= es opcional sobre cada uno, así que @trace fn calc()
con @metric produce span calc + métricas calc_duration_seconds
y calc_calls_total.
@trace
fn just_trace(s: Str) -> Str { return s }
@metric
fn just_metric(n: Int) -> Int { return n + 1 }
Cuándo usarlos:
- Sobre fns que cuestan tiempo (queries custom, validaciones
pesadas, transformaciones de datos) — el histogram te muestra
P50/P95/P99 en Grafana.
- Sobre paths críticos del business que el access log del request
no separa (típico: validate_payment adentro de un POST /orders).
- Para correlacionar logs cross-handler — el span nombrado aparece
como parent en Jaeger/Tempo.
Cuándo NO:
- Sobre HTTP/WS handlers (@get/@post/etc) — el checker rechaza
el stack, porque la auto-instrumentation Fase 12.3 ya cubre esos
casos con span + access log + métricas. Apilar duplicaría el
trabajo y confundiría el backend.
- Sobre fns triviales (un return de un literal) — el overhead del
span entered + Drop guard es ~µs, despreciable, pero el ruido
en el backend molesta.
Costo runtime:
- Cero si NO hay subscriber tracing instalado (programa CLI puro
sin log.X(...) ni HTTP) — el span macro expande a no-op.
- ~µs por call cuando hay subscriber — RAII guard que registra
métricas al Drop. Funciona con return X explícito sin código
muerto (Rust corre Drop antes de retornar).
Paridad fitz run ↔ fitz build:
- En el intérprete, los decoradores son no-op honesto: el
checker valida el shape (kwarg name=, no positional, no
stacking con HTTP), el evaluator los procesa silenciosamente.
Ningún backend real recibe nada — es exclusivamente para tipar
consistentemente con el modo compilado.
- En fitz build la instrumentación es real: el span entra, las
métricas se registran, el subscriber tracing instalado por
Fase 12.3 (cuando hay HTTP) las routea.
$ fitz build calc.fitz
$ ./calc.exe
# stderr: span events de tracing si OTEL_EXPORTER_OTLP_ENDPOINT
# /metrics: histogram calc_duration_seconds + counter calc_calls_total
# (si @server(prometheus=true))
Ejemplo runnable: examples/guide/34-trace-metric.fitz.
33.6. Patrón canónico — stack completo¶
Lo que armás en una app de producción típica:
type User { id: Int, name: Str, role: Str }
@auth_provider
async fn auth(headers: Map<Str, Str>) -> Result<User> {
let token = headers.get("authorization")?
let claims = jwt.decode(token, env("JWT_SECRET"))?
return Ok(User { id: claims["sub"], name: claims["name"], role: claims["role"] })
}
@authenticated @get("/me")
async fn me(user: User) -> Result<User> {
log.info("/me hit", user_id: user.id) // ← trace_id auto
return Ok(user)
}
// `prometheus=true` activa el scrape endpoint. OTel se activa
// solo si OTEL_EXPORTER_OTLP_ENDPOINT está seteada (default off).
@server(3000, prometheus=true)
fn cfg() => 0
Deployás con env vars para activar OTel cuando lo necesités:
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 \
OTEL_SERVICE_NAME=my-service \
FITZ_LOG=info \
./mi-app
Resultado: cada request abre un span en Jaeger, los logs salen
a stderr con trace_id (que Loki indexa), y /metrics se
puede scrapear con Prometheus. Todo correlacionado, todo
out-of-the-box.
33.7. Recetas comunes¶
Capturar contexto custom adentro de un span:
@get("/orders/{id}")
async fn get_order(id: Int) -> Result<Order> {
log.debug("Fetching order", order_id: id)
let order = orders.find(fn(o) => o.id == id)?
log.info("Order found", order_id: id, total: order.total)
return Ok(order)
}
Logs estructurados sin HTTP (CLI puros también soportan
log.*):
@command("import")
fn import_data(file: Str) -> Int {
log.info("Iniciando import", file: file)
// ... lógica ...
log.info("Import completo", rows: 1234)
return 0
}
(En CLI puros no hay trace_id automático — no hay request
context. Pero los logs estructurados, filtros, y JSON output
funcionan igual.)
Custom Counter/Histogram desde código del user (manual con
el crate metrics re-exportado, deuda menor — hoy podés
emitir los nombres reservados desde el wrapper y opt-out con
@server(observability=false) si chocan).
Health checks dedicados con decorators @healthz /
@readyz (Fase 12.1) — están separados del flujo de
observability pero se combinan bien:
@healthz
fn alive() -> Bool => true
@readyz
async fn ready(db: DbConn) -> Result<Bool> {
let _ = db.query("SELECT 1").await?
return Ok(true)
}
33.8. Qué NO hace Fitz (y por qué)¶
- Spans anidados ad-hoc adentro de una fn —
@traceabre un span sobre toda la fn, pero NO hay API para abrir un span hijo en una sub-sección (por ejemplo,span("db query"): { ... }envolviendo solo unas líneas). Workaround: extraé la sección a una fn dedicada y poneles@tracearriba. Los logs heredan eltrace_idigual. - Bridge métricas OTel (deuda residual #1 de Fase 12.3).
Las métricas van a Prometheus si
prometheus=true, o no van a ningún lado siprometheus=false(Counter/Histogram emitidos a recorder vacío). El cratemetrics-exporter-opentelemetry = "0.2.1"que cerraría esta deuda está pineado aopentelemetry_sdk = "0.31"mientras nosotros estamos en 0.32 — esperando release nuevo del crate. Mientras tanto: scraper Prometheus → OTel collector cubre el caso. - Logs sin HTTP no llegan a OTel logs signal — el bridge iter2.b funciona pero requiere SpanContext activo (caso HTTP); CLI puros emiten a stderr únicamente. Refinable cuando entre demanda.
33.9. Lo que viene¶
- Cap dedicado de OTel queda pendiente de actualizar cuando cierre Tier 2 (bridge métricas OTel). Mientras tanto, este cap documenta el feature completo end-to-end con el workaround Prometheus.
- Spans hijos ad-hoc adentro de fns: la API actual con
@traceenvuelve la fn entera. Sub-sección que abra/cierre un span sólo sobre unas líneas queda como deuda menor (workaround: extraer la sección a fn dedicada y ponerle@trace).
33.10. Deployment — fitz docker init + fitz docker build (Fase 12.4)¶
Para llevar todo el stack (HTTP + logs + spans + métricas + DB) a
producción dentro de un container, Fitz tiene dos sub-comandos:
fitz docker init genera Dockerfile + .dockerignore + docker-compose.yml
smart por defecto; fitz docker build [--tag X] tag-ea y delega a
docker build.
$ fitz docker init
▶ fitz docker init — proyecto `mi-api` en `/path/al/proyecto`
detectado: @server(port = 3000)
detectado: uso de DB (db.X(...)) → compose suma postgres:16-alpine
detectado: interop Python → runtime fallback a python:3.12-slim-bookworm (libpython3.12 + wget)
detectado: @cron → compose suma restart: unless-stopped
✓ escrito: Dockerfile
✓ escrito: .dockerignore
✓ escrito: docker-compose.yml
El comando lee el fitz.toml del cwd (o ancestros), parsea el
entry point declarado en [bin].main, y emite:
Dockerfilemulti-stage: builderghcr.io/thegreekman76/fitz:${FITZ_TAG}conRUN fitz build→ runtime adaptativo según el shape del programa:- Por defecto
gcr.io/distroless/cc-debian12(~22 MB sin shell ni package manager — superficie mínima). - Cuando el programa declara
from python import X, cae apython:3.12-slim-bookworm(~55 MB con libpython3.12 + wget). El binario emitido porfitz buildcon interop dynamic-linkealibpython3.12.soque distroless no incluye. EXPOSE <port>se emite automáticamente si el programa declara@server(N)..dockerignorecontarget/,.git/,.env*,__pycache__/, configs de editor, etc.docker-compose.ymlsmart adaptativo:- Si el programa usa
db.connect(...)o cualquierdb.X(...)nativo Fitz, el compose suma un servicepostgres:16-alpinecon healthcheck + volumepgdata+DATABASE_URLinyectada condepends_on: service_healthy. - Si el programa tiene
@cron, sumarestart: unless-stoppedal service principal para que el scheduler sobreviva crashes/redeploys. - Si hay
@server(port)Y el runtime tiene wget disponible (uses_python→ slim-bookworm), suma bloquehealthcheck:HTTP contra/healthz(auto-mounteado por Fase 12.1.b). Con distroless el compose emite un comentario explicando cómo agregarlo a mano si el user cambia el runtime.
Para sobrescribir archivos existentes:
Sin --force, los archivos existentes se preservan (cero overwrite
accidental de un Dockerfile hand-tuned).
Para construir la imagen sin escribir docker build a mano:
$ fitz docker build # tag: <package.name>:latest
$ fitz docker build --tag mi/app:v1 # override del tag
El wrapper es thin — invoca docker build -t <tag> . en el directorio
del manifest y propaga el exit code. Aborta con sugerencia clara si
falta Dockerfile (recomendando correr fitz docker init primero).
Producción con OTel: cuando arranques el container, exportá las env vars OTel y el binario reportará al backend automáticamente:
services:
app:
build: .
environment:
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
OTEL_SERVICE_NAME: "mi-api-prod"
OTEL_TRACES_SAMPLER_ARG: "0.1" # 10% sampling
RUST_LOG: "info"
FITZ_LOG_FORMAT: "json"
# Nota: `FITZ_PROMETHEUS=1` ya NO funciona como runtime
# override desde v0.13.1. Para activar `/metrics`, declarar
# `@server(3000, prometheus=true)` en el código fuente.
Limitaciones conocidas de Fase 12.4 (deuda residual visible):
- DB indirecta vía interop Python: el detector
uses_dbsolo matcheadb.X(...)nativo Fitz. Programas que usan SQLAlchemy a través defrom python import sqlalchemyno disparan el servicedb:en compose. Workaround: usar--forcey editar a mano, o usar el driver Postgres nativo de Fitz (cap 31). - Healthcheck HTTP sin distroless: el bloque solo sale cuando el
runtime tiene wget (
uses_python). Para programas no-Python con@server, el user puede agregar el healthcheck a mano o cambiar el runtime (el comentario en el compose explica cómo). - Cross-module detection: el shape se calcula del entry point del
manifest.
@server/db.connect/@cron/from python import Xadentro de un módulo importado no dispara el shape. Workaround: declarar todo en el archivo principal (caso típico). fitz docker buildthin: sin--push/--platform/--no-cache. Para CI multi-platform condocker buildx, correrdocker builddirecto.
33.11. Feature flags con @flag, flag() y flags.* (Fase 12.8)¶
Tercera pieza del Tier 2 de Fase 12 (deploy + @trace/@metric +
flags). El decorator @flag("name") gate-a una fn entera; el
builtin flag("name") -> Bool consulta el registry desde cualquier
expresión.
@flag("new-checkout")
@get("/v2/checkout")
fn v2_checkout() -> Map<Str, Str> {
return {"status": "ok", "version": "v2"}
}
@get("/v1/checkout")
fn v1_checkout() -> Map<Str, Str> {
return {"status": "ok", "version": "v1"}
}
fn main() {
if flag("dark-mode") {
print("dark mode activado")
}
print("flags conocidas: {flags.list()}")
}
Cómo se configuran:
-
Sección
El runtime ([flags]enfitz.toml— defaults compile-time:fitz run) y el binario nativo (fitz build) leen esta sección al boot. Defaults baked-in al binario; cambiarlos exige recompilar. -
Env vars
La env var gana al default del manifest. Valores aceptados:FITZ_FLAG_<UPPERCASE>— override runtime sin recompilar:true/1/yes/onyfalse/0/no/off(case-insensitive). Cualquier otro string → cae al default. -
Default
false— sin manifest ni env var, los flags están desactivados (política fail-safe: features nuevas opt-in).
Semántica del decorator @flag("name"):
- Sobre HTTP handlers (@get/@post/@put/@delete) o WS
handlers (@ws): si el flag está off, el wrapper retorna 404
con {"error": "feature 'name' disabled"} antes de tocar
middlewares, auth, body parsing. Hot path.
- Sobre fns regulares: el decorator NO altera el cuerpo en el
MVP — el bloqueo se hace declarativo via flag(name) dentro de la
fn. (Patrón canónico: combiná @flag con HTTP para gate routes,
o usá if flag(name) puntual adentro del código.)
Builtins:
flag("name") -> Bool // alias funcional
flags.is_enabled("name") -> Bool // identico a flag()
flags.list() -> List<Str> // nombres conocidos
flags.list() devuelve todos los flags conocidos en orden
alfabético (BTreeSet interno): defaults del manifest + flags
detectados via env vars FITZ_FLAG_* (lowercase del suffix). Útil
para dashboards admin o debug.
Cuándo usarlos:
- Canary releases — @flag("new-payment-flow") sobre la ruta
v2; flag off en producción, on en staging, on para % de tráfico
via env var por instance.
- Kill switches — @flag("expensive-feature") que apagás vía
env var sin redeploy si la feature tira el sistema.
- A/B testing declarativo — toggleás por instance o por env
variable la versión del handler.
- Beta gated — features que solo internal users ven hasta que
estén listas. Combinable con @admin/@requires para gate por
role + flag al mismo tiempo.
Paridad bit-a-bit fitz run ↔ fitz build:
- Intérprete: registro global cargado en resolve_entry desde
manifest.flags, override por env var, cache lookup.
- Binario nativo: registro estático __FitzFlagRegistry con
OnceLock, defaults baked-in via __fitz_flag_init(...) al main,
mismo lookup + cache.
- Ambos modos retornan los mismos resultados para el mismo manifest
+ env vars + flag name.
Combinable con todo el stack:
@flag("new-checkout")
@requires("editor")
@authenticated
@post("/v2/checkout")
async fn v2(body: CheckoutInput, user: User) -> Result<Receipt> {
// body parseado, user authenticated + role=editor, flag on
return Ok(process_checkout(body, user))
}
Si la flag está off → 404 inmediato (ANTES de cualquier check de
auth). Si la flag está on pero el user no tiene role editor → 403.
Si la flag está on, user role OK pero no auth → 401.
Lo que NO está en el MVP:
- Flags scoped por user/request — el flag es global por
proceso. Para Patrones tipo "30% de tráfico" o "solo users beta",
consultá un servicio externo (LaunchDarkly/Unleash/Flagsmith)
desde el handler.
- Hot-reload de flags sin restart — los flags son inmutables
durante el lifetime del proceso. Cambio de env var requiere
reinicio. Mitigable wrappando consultas con TTL si aparece
demanda real.
- Decorator @flag sobre fns regulares (no HTTP/WS) — el shape
está validado pero la semántica del MVP es no-op: usá
if flag(name) dentro del cuerpo de la fn para gating manual.
Ejemplo runnable: examples/guide/34b-feature-flags.fitz.
34. CLI builder nativo (@command)¶
Hito de Fase 13 (v0.11.0) — Fitz tiene un CLI builder nativo en
el core del lenguaje. Una fn decorada con @command("name", desc="...")
declara un comando CLI; el binario producido por fitz build parsea
std::env::args() y dispatcha al comando correspondiente, con
help auto-generado y parser de positional args + flags con
zero deps externas.
Panorama vecino¶
| Lenguaje / framework | Stack típico | Zero deps | Help auto | Tipado estático |
|---|---|---|---|---|
| Python | argparse / click / typer |
❌ (stdlib o pip) | ✅ | ❌ |
| Rust | clap (derive) |
❌ (clap) | ✅ | ✅ |
| Go | flag stdlib + manual |
✅ (stdlib) | ⚠️ | ⚠️ |
| Node.js | commander / yargs |
❌ (npm) | ✅ | ❌ |
| Fitz (v0.11.0) | @command decorator |
✅ | ✅ | ✅ |
Sintaxis básica¶
/// Greet a person with optional volume + repetition.
@command("greet", desc="Greet a person")
fn greet(name: Str, loud: Bool = false, count: Int = 1) -> Int {
let n = count
while n > 0 {
if loud {
print("HELLO, {name}!")
} else {
print("hello, {name}")
}
n = n - 1
}
return 0
}
Después de fitz build greeter.fitz:
$ ./greeter Ada
hello, Ada
$ ./greeter Ada --loud
HELLO, Ada!
$ ./greeter Ada --loud --count 3
HELLO, Ada!
HELLO, Ada!
HELLO, Ada!
$ ./greeter --help
USAGE:
greeter <name> [OPTIONS]
ARGS:
<name> (Str)
OPTIONS:
--loud
--count <INT>
-h, --help
Convención de params (sin decorators extras)¶
Fitz NO requiere decorators sobre cada param. La convención es directa:
| Patrón Fitz | Resultado CLI |
|---|---|
name: Str (sin default) |
Positional arg requerido <name> |
loud: Bool = false |
Flag bool sin valor --loud |
count: Int = 1 |
Flag con valor --count <N> (default 1) |
host: Str = "localhost" |
Flag con valor --host <STR> |
rate: Float = 0.5 |
Flag con valor --rate <FLOAT> |
Reglas:
- Params sin default → positional args, en orden de declaración.
- Params con default → flags opcionales.
- Bool = false → flag boolean; presencia = true.
- Bool = true queda como deuda menor (requiere convención
--no-flag para negar). Por ahora invertí la lógica.
- El return type debe ser Int — es el exit code.
Subcomandos (multi-command)¶
Si declarás 2+ @command en el mismo programa, son subcomandos:
@command("greet", desc="Greet a person")
fn greet(name: Str, loud: Bool = false) -> Int {
if loud { print("HELLO, {name}!") } else { print("hello, {name}") }
return 0
}
@command("status", desc="Show service status")
fn status() -> Int {
print("status: OK")
return 0
}
$ ./mybin --help
USAGE:
mybin <command> [ARGS] [OPTIONS]
COMMANDS:
greet Greet a person
status Show service status
Run `mybin <command> --help` for more info on a specific command.
$ ./mybin greet Ada
hello, Ada
$ ./mybin status
status: OK
$ ./mybin greet --help
Greet a person
USAGE:
mybin greet <name> [OPTIONS]
ARGS:
<name> (Str)
OPTIONS:
--loud
-h, --help
Exit codes¶
Convención POSIX:
- 0 → éxito (handler retornó 0).
- 1+ → error retornado explícitamente por el handler (return 1).
- 2 → error de parsing del CLI (comando desconocido, arg faltante,
tipo inválido). El help se imprime al stderr.
@command("fail")
fn fail(reason: Str) -> Int {
print("error: {reason}", file="stderr")
return 7 // exit code propagado al shell
}
Detección automática del modo binario¶
fitz build detecta el modo según los decorators del programa:
| Decorators en el programa | Modo del binario |
|---|---|
@get/@post/@put/@delete/@ws |
HTTP server (axum + tokio) |
@cron (sin HTTP) |
Cron-only scheduler bloqueante |
@command (sin HTTP, sin cron) |
CLI tool (parser argv + dispatch) |
| Ninguno | Script CLI plano (ejecuta main_stmts) |
Mutuamente excluyentes: el checker rechaza @command + @get,
@command + @cron, @command + @test, etc. con error claro.
Modo intérprete (fitz run)¶
El intérprete también soporta el dispatch CLI — útil para
development y para tests. Pasás los args del programa después de
-- para separarlos de los args de fitz:
Paridad bit-a-bit: el output del intérprete coincide
exactamente con el del binario compilado para el mismo argv. Validado
con E2E (tests/compile_e2e.rs::fase_13_cli_paridad_run_vs_build).
Por qué Fitz hace esto distinto¶
- Zero deps externas. El parser de argv vive en
src/cli.rs(~600 LoC), compilado dentro del binariofitz. El binario producido porfitz buildemite el dispatch inline; ceroCargo.tomlextras, ceropip install. Single binary. - Estático no reflection. Los
@commandse enumeran en compile-time; el dispatch es unmatchestático sobre el nombre del comando, no un dict lookup runtime (como Click/Fire). Typo en el nombre del comando se detecta antes de correr. - Convención sin decorators extras. Click (Python) exige
@click.argument(...)y@click.option(...)por cada param. Fitz infiere todo del shape del param: con/sin default determina positional vs flag. Menos verbosidad para el caso común. - Help auto consistente. El help string se construye en build-time desde los decorators + nombres + tipos. El intérprete y el binario emiten exactamente el mismo formato.
- Tipado estático del exit code. El checker exige
Intcomo return type. Olvidásreturn 0→ error de compilación. En Python/Node tenés que recordarsys.exit()manualmente.
Tipos soportados en params¶
| Tipo Fitz | Marshalleable en CLI | Cómo se parsea |
|---|---|---|
Str |
✅ | Pasa directo al param |
Int |
✅ | parse::<i64>() con error claro |
Float |
✅ | parse::<f64>() |
Bool |
✅ | Flag presence = true; --name=false override |
Str? |
✅ | None si no se pasa (deuda menor en MVP) |
List<T> |
❌ (deuda futura) | Bajá a Str separado por comas y .split |
Map |
❌ | No tiene sentido en CLI |
Lo que viene¶
@flag(short="l")para short flags (-lademás de--loud). Deuda menor — el parser ya soporta el shape, falta el wiring del decorator.Bool = truecon--no-flagnegación. Convención de Click que esperan algunos users.- Subgrupos de comandos (
mybin orm migratevsmybin orm seed). Más complejo — requiere parser jerárquico. List<Str>variádicos (mybin run file1.txt file2.txt). Buena para herramientas tipocatols.- Stdin parsing (
mybin --stdinlee config de stdin como JSON). Deuda futura.
Cap completo del CLI builder: ver examples/guide/33-cli.fitz para
el ejemplo runnable + boilerplates/cli-tool/ para un proyecto CLI
completo con multiple comandos.
35. Deployment ciudadano primera clase¶
Hito de Fase 12 (v0.12.0 → v0.12.4) — Fitz tiene
deployment ciudadano de primera clase: las piezas que en otros
lenguajes son librerías opt-in (health probes, secrets management,
observability, Dockerfiles) están en el core del compilador con
detección AST y paridad bit-a-bit fitz run ↔ fitz build.
Este capítulo es la vista integradora — junta los 4 sub-pasos de Fase
12 (healthz/readyz + Secret + OTel + Docker autogenerado) y muestra
cómo el mismo programa Fitz pasa de fitz run local a un container
en producción sin cambiar una línea de código, sólo agregando env
vars y un sub-comando.
Panorama vecino¶
| Lenguaje | Health probes | Secrets | OTel | Dockerfile autogenerado |
|---|---|---|---|---|
| Python | flask-healthz / FastAPI custom | python-dotenv + os.getenv | opentelemetry-api + 6 paquetes | ❌ (manual o cookiecutter) |
| TypeScript | terminus + manual handlers | dotenv + process.env | @opentelemetry/* + setup manual | ❌ (manual o Nx generators) |
| Go | gorilla/mux handlers manuales | godotenv o env-config | go.opentelemetry.io/otel + setup | ❌ (manual) |
| Spring (Java) | actuator (opcional) | @ConfigurationProperties | spring-cloud-sleuth opcional | ❌ (manual o jib) |
| Fitz | auto-mount /healthz + /readyz |
secret(key) + config(key) built-ins |
estructurado + OTLP built-in | fitz docker init smart |
Ningún otro lenguaje moderno combina las 4 piezas como features intrínsecas del compilador, con detección automática del shape del programa y paridad bit-a-bit intérprete↔binario.
35.1. El stack de Fase 12 en una mirada¶
Las 4 piezas trabajan juntas — cada feature genera información que las otras consumen:
┌─────────────────────────────────────────────────────────┐
│ PROGRAMA FITZ │
│ │
│ @server(3000) @healthz @cron("*") │
│ @get(...) @readyz @background │
│ @authenticated db.connect(...) log.info() │
│ fn handler(...) secret(...) │
└─────────────────────────────────────────────────────────┘
│
▼
┌────────────┬────────────┬────────────┐
│ 12.1 │ 12.2 │ 12.3 │ 12.4
│ healthz + │ Secret<T> │ Logs + │ Dockerfile
│ readyz + │ + config() │ Spans + │ + compose
│ SIGTERM │ + secret() │ Métricas + │ smart por
│ drain │ │ OTel │ detección AST
│ auto- │ │ │
│ mount │ │ │
└────────────┴────────────┴────────────┴────────────┘
│
▼
BINARIO STANDALONE
(~22-55 MB, distroless)
│
▼
┌────────────────────────────────────┐
│ fitz docker build │
│ docker compose up │
│ kubectl apply / fly deploy │
└────────────────────────────────────┘
35.2. Healthz y readyz (auto-mount, Fase 12.1)¶
Todo HTTP server Fitz expone /healthz y /readyz automáticamente
— sin código, sin libs, sin handlers manuales. El binario los emite
desde el wrapper interno del servidor (Fase 12.1.b):
/healthz (liveness probe K8s) — responde 200 OK mientras el
proceso esté vivo. Si el programa está en deadlock o panicó, axum no
responde, y K8s lo reinicia.
/readyz (readiness probe K8s) — responde 200 OK cuando el
proceso está listo para recibir tráfico, 503 Service Unavailable
durante el "draining" (SIGTERM ya disparó). Esto habilita
rolling deploys con cero downtime: K8s deja de rutear tráfico al
pod viejo antes de matarlo.
Override opcional con handlers custom — si el programa declara
@get("/healthz") o el decorator dedicado @healthz fn ..., Fitz lo
prioriza sobre el auto-mount default. Patrón canónico:
@healthz
fn check_db_alive() -> Bool {
// True si la DB responde a SELECT 1, false si no.
return db.is_closed() == false
}
@readyz
fn check_warmup() -> Bool {
// Por ejemplo: false hasta que el cache cargó.
return cache_ready
}
Las dos fns deben retornar Bool. Si retornan false, el endpoint
devuelve 503 Service Unavailable. El Drain para readyz se activa
automático al recibir SIGTERM.
Detalle completo: cap 33.6 de esta misma guía (Observability
incluye los detalles del SpanContext y los headers OTel-compatibles
que /healthz también propaga).
35.3. Secrets y config tipados (Fase 12.2)¶
secret(key) -> Secret<Str> y config(key) -> Str son built-ins que
leen env vars con dos semánticas distintas:
config("DATABASE_HOST", "localhost")— devuelveStrdirecto. Toma(key, default)— si la env var no existe, usa el default. Para configuración no sensitive: hostnames, ports, log levels.secret("DATABASE_PASSWORD")— devuelveSecret<Str>opaco. Toma sólo lakey; si la env var no existe, error de runtime (los secrets deben estar definidos explícitamente, sin defaults silenciosos). No se puede imprimir, no se puede loggear, no se puede serializar a JSON. Para extraer el valor real hay que llamar.expose()explícitamente.
let db_host = config("DATABASE_HOST", "localhost") // Str — público
let db_pass = secret("DATABASE_PASSWORD") // Secret<Str> — opaco
log.info("conectando a {db_host}") // OK
log.info("password={db_pass}") // ❌ checker error
log.info("password={db_pass.expose()}") // OK pero feo a propósito
let url = "postgres://{db_pass.expose()}@{db_host}:5432/app"
let conn = db.connect(url)?
Redacción automática en logs/JSON — si un valor Secret<Str> cae
adentro de un kwarg de log.info(...), se redacta a "***"
automáticamente. Esto evita que un developer distraído filtre
credenciales por accidente:
log.info("login", user: user, password: pass)
// → {"timestamp":"...","level":"INFO","msg":"login","user":{...},"password":"***"}
El Secret<T> también se redacta dentro de List<Secret<T>> y
Map<Str, Secret<T>> recursivamente.
Patrón canónico de producción:
@server(3000)
fn main() {
let db_url = "postgres://{secret("DB_USER").expose()}:{secret("DB_PASS").expose()}@{config("DB_HOST", "localhost")}:5432/{config("DB_NAME", "app")}"
print("conectando a {config("DB_HOST", "localhost")}")
db.connect(db_url)?
}
Las credenciales nunca aparecen en logs, ni siquiera por accidente.
El config("DB_HOST", default) es seguro de loggear.
35.4. Observability con OTel (Fase 12.3)¶
Detalle completo en cap 33. Resumen para deployment:
- Logs estructurados → JSON a stderr por default; pretty en TTY o
con
FITZ_LOG_FORMAT=pretty. Filter conFITZ_LOG=info|debug|warn. - Spans HTTP → cada request abre un span root con
trace_id/span_id(32+16 hex), todos loslog.*adentro heredan el contexto. - Métricas → Counter
http_requests_total{method,path,status}+ Histogramhttp_request_duration_seconds{...}emitidos automático. - OTLP exporter → seteando
OTEL_EXPORTER_OTLP_ENDPOINT,OTEL_SERVICE_NAMEyOTEL_TRACES_SAMPLER_ARG, los spans y logs van al backend (Jaeger, Tempo, Honeycomb, Datadog, etc.). - Endpoint
/metricsPrometheus → activable con@server(prometheus=true)literal en código. Desde v0.13.1 el path env varFITZ_PROMETHEUS=1ya no funciona como override de runtime (ver cap 33.4).
35.5. Dockerfile + compose autogenerados (Fase 12.4)¶
fitz docker init lee el fitz.toml del cwd, parsea el entry point,
detecta el shape del programa, y emite:
Dockerfilemulti-stage con runtime adaptativo (distroless/cc-debian12por default,python:3.12-slim-bookwormcuandofrom python import X)..dockerignorecon los excludes típicos.docker-compose.ymlsmart: sumapostgres:16-alpinesi detectadb.X(...),restart: unless-stoppedcon@cron, healthcheck HTTP cuando hay@server+ wget disponible.
$ fitz docker init
▶ fitz docker init — proyecto `mi-api` en `/path/al/proyecto`
detectado: @server(port = 3000)
detectado: uso de DB (db.X(...)) → compose suma postgres:16-alpine
✓ escrito: Dockerfile
✓ escrito: .dockerignore
✓ escrito: docker-compose.yml
$ fitz docker build --tag mi-api:v1.0
▶ fitz docker build — tag `mi-api:v1.0` en `/path/al/proyecto`
...
✓ build OK — `mi-api:v1.0`
Detalle completo en cap 33.10.
35.6. Ejemplo runnable: API de producción end-to-end¶
examples/guide/35-deploy.fitz arma un microservicio que combina
todo el stack de Fase 12 en <100 LoC. Un endpoint público + un
endpoint protegido + DB + logs estructurados + healthz custom + uso
de secret()/config():
type User { id: Int, name: Str, role: Str }
@auth_provider
async fn check(headers: Map<Str, Str>) -> Result<User> {
match headers.get("authorization") {
Ok(token) => {
// Validar token contra cache/DB...
if (token == "Bearer demo-admin") {
return Ok(User { id: 1, name: "Ada", role: "admin" })
}
return Err("token inválido")
}
Err(_) => return Err("falta Authorization")
}
}
@get("/")
fn root() -> Map<Str, Str> {
log.info("root hit")
return {"app": config("APP_NAME", "demo"), "version": "v1.0"}
}
@admin
@get("/admin/stats")
async fn stats(user: User) -> Map<Str, Int> {
log.info("stats request", admin: user.name)
return {"users": 42, "online": 7}
}
@healthz
async fn db_alive() -> Bool {
// Devuelve false si la DB no responde.
return db.is_closed() == false
}
@server(3000, "0.0.0.0")
fn main() {
let db_url = "postgres://{secret("DB_USER").expose()}:{secret("DB_PASS").expose()}@{config("DB_HOST", "localhost")}:5432/{config("DB_NAME", "app")}"
log.info("conectando", host: config("DB_HOST", "localhost"))
db.connect(db_url)?
log.info("server listo")
}
Local dev (fitz run):
$ export DB_USER=fitz DB_PASS=fitz DB_HOST=localhost DB_NAME=mi_app APP_NAME=demo
$ fitz run examples/guide/35-deploy.fitz
{"timestamp":"...","level":"INFO","msg":"conectando","host":"localhost"}
{"timestamp":"...","level":"INFO","msg":"server listo"}
Production deploy (fitz docker init + compose):
$ fitz docker init # genera Dockerfile + .dockerignore + compose.yml
$ cat > .env <<EOF
APP_NAME=mi-app-prod
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_SERVICE_NAME=mi-app-prod
OTEL_TRACES_SAMPLER_ARG=0.1
RUST_LOG=info
FITZ_LOG_FORMAT=json
# FITZ_PROMETHEUS=1 # removido en v0.13.1 — Prometheus se
# activa con @server(prometheus=true)
# literal en el código (ver cap 33.4).
DB_USER=postgres
DB_PASS=$(openssl rand -hex 32)
DB_HOST=db
DB_NAME=mi_app
EOF
$ docker compose up --build
El mismo programa Fitz pasa de local a producción sin cambiar una línea de código. Solo cambian env vars y el sub-comando.
35.7. Patrones canónicos de producción¶
12-factor compliance built-in — Fitz cubre los 12 factores de forma idiomática:
| Factor | Cómo Fitz lo cubre |
|---|---|
| I. Codebase | fitz.toml + git (Fase 9.y). |
| II. Dependencies | fitz.lock + Cargo.toml emitido determinístico. |
| III. Config | config(key) + secret(key) lee env vars (Fase 12.2). |
| IV. Backing services | db.connect(url_from_env) (Fase 10). |
| V. Build/release/run | fitz build separa del runtime; binario standalone. |
| VI. Processes | Stateless por default; jobs in-memory + persistencia DB. |
| VII. Port binding | @server(N, "0.0.0.0") explícito. |
| VIII. Concurrency | Tokio multi-thread, scale horizontal. |
| IX. Disposability | SIGTERM drain automático (/readyz → 503) + 30s grace. |
| X. Dev/prod parity | Mismo binario, mismas env vars, paridad bit-a-bit. |
| XI. Logs | JSON estructurado a stderr (Fase 12.3.a). |
| XII. Admin processes | @command("migrate") + fitz run ad-hoc. |
Smart Dockerfile selection — fitz docker init elige el runtime
automático según el shape del programa:
- Sin interop Python →
gcr.io/distroless/cc-debian12(~22 MB). - Con
from python import X→python:3.12-slim-bookworm(~55 MB). - Con
@cron→ sumarestart: unless-stoppedal compose. - Con
@server→ sumaEXPOSE+ports:. - Con
db.X(...)→ suma servicepostgres:16-alpinecon healthcheck.
Cero pip install para features intrínsecas — JWT, Argon2,
logs, spans, métricas, OTLP exporter, Prometheus exporter, todo
embebido en el binario fitz. Compará con FastAPI o Express donde
cada una es un paquete separado.
Paridad bit-a-bit — el mismo programa corre idéntico con
fitz run (intérprete) y fitz build && ./binario (Rust nativo).
Esto incluye los headers OTel, las métricas, el shape del JSON de
logs, los códigos de status. No hay "feature X solo funciona en
build" o viceversa.
35.8. Qué no está en el MVP¶
Fase 12 cierra deployment-as-a-citizen al 95%. Lo que falta es deuda visible documentada:
fitz deployorchestrator — un comando que ejecuta el deploy según target (docker compose up,fly deploy,kubectl apply). Cap 37 ("Qué sigue") lo menciona como Fase 12.6 diferida (sin demanda real todavía).- Auto-mount de
/auth/refresh+/auth/logout— el RBAC custom de v0.12.4 (@requires("role")) cubre autorización. La revocación de tokens necesita los builtinsauth.blacklist/auth.is_blacklisted - tabla
fitz_token_blacklist(sub-iter 9.w.1.iter2.b pendiente). Hasta entonces los handlers/auth/logouty/auth/refreshse escriben a mano. - Spans hijos ad-hoc dentro de una fn —
@trace(Fase 12.7, v0.13.0) cubre fns enteras. Spans que envuelvan solo unas líneas adentro de la fn quedan como deuda menor. Workaround: extraer la sección a una fn dedicada con@tracearriba. - Feature flags —
@flag("name") fn ...+flag("name") -> Bool - config via TOML/env. Sin demanda concreta, diferido a Fase 12.8.
- Healthcheck HTTP en distroless — el compose smart solo emite healthcheck cuando hay wget disponible (slim-bookworm), porque distroless no tiene shell. Workaround documentado en cap 33.10 (cambiar runtime o agregar sidecar).
- CPython embebido en
fitz build—--bundle-pythonya está cerrado (cap 21.11),--bundle-pippara paquetes pip externos está parcial. Los boilerplates ⅚ hoy usanpython:3.X-slim; cuando el sub-paso completo aterrice, pasan aFROM scratch.
36. Plantillas y boilerplates¶
Si llegaste hasta acá leyendo, ya viste cada feature de Fitz por separado: HTTP nativo, auth, WebSockets, jobs, interop Python, package manager, compilación a binario. Lo que sigue es verlas funcionando juntas en proyectos reales.
El repo trae 6 boilerplates Dockerizados en boilerplates/,
cada uno con README exhaustivo (paso a paso, troubleshooting,
plan de producción):
| Boilerplate | Qué demuestra | Setup |
|---|---|---|
cli-tool |
CLI puro — sales report con .reduce/.filter/.map. Binario nativo distroless ~30 MB. |
docker build . |
api-simple |
REST API tipada con OpenAPI 3.1 + UI Scalar autogenerados. Distroless ~40 MB. | docker build . |
api-middleware-cors |
Auth nativa JWT + Argon2id + middleware encadenado + CORS cross-origin + frontend vanilla. | compose 2 svcs |
api-websocket |
WebSockets tipados (WsConn<T>) con broadcast + heartbeat + auth + frontend chat vanilla. |
compose 2 svcs |
api-postgres-python |
CRUD multi-archivo con SQLAlchemy + Postgres (módulos types/ + data/, ejercitable con curl). |
compose 2 svcs |
api-fullstack-postgres |
Showcase fullstack — API + frontend rico (tabla, edit inline, filtros, badges) + Postgres en compose. | compose 3 svcs |
Quickstart genérico¶
cd boilerplates/<nombre>
cp .env.example .env # si existe
docker compose up --build # o `docker build .` si no hay compose
Cada README dice qué URL abrir y cómo probar con curl.
Cómo elegir¶
- Aprendiendo Fitz → arrancá por
cli-tool: binario standalone, sin HTTP ni DB, muestra types + listas + funcional puro en <100 LoC. - REST API mínima →
api-simple: GET/POST tipados + OpenAPI auto. - Auth + frontend →
api-middleware-cors: stack stateless más rico, con CORS preflight automático y JWT/Argon2 nativos. - Real-time →
api-websocket: chat broadcast con heartbeat built-in y AsyncAPI 3.0 auto-generado. - Necesitás DB persistente →
api-postgres-pythonpara el patrón API + DB, oapi-fullstack-postgrespara el stack web completo.
Las plantillas son la forma más rápida de ver Fitz "en escala real", y también el mejor punto de partida si querés extender en lugar de escribir desde cero.
Nota sobre
--bundle-python: los boilerplates 5 y 6 (api-postgres-python,api-fullstack-postgres) usanpip install sqlalchemy psycopg2adentro del runtime — la imagen Docker base sigue siendopython:3.X-slimporque el flag actualfitz build --bundle-pythonempaqueta CPython base + stdlib pero NO paquetes pip. Cuando el sub-paso futuro--bundle-pipaterrice, esos Dockerfiles podrán pasar aFROM scratchoFROM gcr.io/distroless/cc-debian12con un binario único de ~80-100 MB (CPython + paquetes pip + el real binary). Hasta entonces, el modelo "match builder/runtime Python" sigue siendo el correcto. Para programas Fitz que solo usan Python stdlib (sin pip packages),--bundle-pythonhoy ya produce binarios standalone listos paraFROM scratch— verexamples/python-interop-8.b.fitzy cap 21.11.
37. Qué sigue¶
Si llegaste hasta acá: gracias. Esta es una versión temprana de la guía y vos sos parte muy temprana del proyecto.
Lo que ya sabés¶
Con los capítulos 1 a 30 podés:
- Escribir y correr programas que combinan variables, aritmética y strings con interpolación.
- Controlar el flujo con
if/else if/else,while,loopyfor ... in, y elegir entre alternativas conmatchsobre literales y rangos. - Agrupar datos en listas, mapas y rangos, accederlos por índice o clave, recorrerlos e iterarlos.
- Definir funciones con su forma de bloque y su forma flecha, hacer recursión y crear closures con captura léxica.
- Declarar tipos custom con
type, instanciarlos (User { id: 1, name: "x" }), acceder a sus campos (user.name), mutarlos (user.name = "Otro"), con defaults y campos nullables. - Llamar métodos sobre listas (
xs.push,xs.map,xs.filter,xs.find), mapas (m.get,m.has,m.keys,m.values), y strings (s.upper,s.lower,s.len), usando funciones anónimas inline (fn(n) => n * 2) como callbacks. - Manejar errores con
Result,Ok,Erry el operador?para propagar, sin excepciones. - Partir el código en módulos con
import fooyfrom foo import a, b. - Escribir APIs HTTP con
@get/@post/@put/@delete, path params tipados, body deserializado contratype, serialización JSON automática, headers con@header(name="X"), y@server(port, host)para configurar. - Obtener OpenAPI 3.1 + UI Scalar gratis en
/openapi.jsony/docs, generados desde los decoradores. Opt-out con@server(docs=false). Schema idéntico bit-a-bit entrefitz runyfitz build(Fase 7). - Leer un mensaje de error del intérprete y ubicar de qué fase vino.
- Validar tipos en compile time con
fitz check, y dejar quefitz runaborte en modo strict cuando encuentra errores (Fase 5a cerró el type checker estático). - Compilar a binario nativo con
fitz build: programa CLI o server HTTP que corre sin Fitz instalado en la máquina destino (Fase 5b — codegen via transpile-a-Rust + Cargo). - Llamar código Python con
from python import X: importar módulos (math,json,sqlalchemy...), invocar funciones con marshaling automático de tipos compuestos (List → list, Map → dict, Instance → dict), manejar errores conResult<T>(excepciones Python →Err), recuperar tipos Fitz desde Python con anotaciones (let row: User = py_call(...)?), auto-generartypeFitz desde SQLAlchemy confitz py-types, yawaitcorutinas Python via bridge tokio ↔ asyncio. - Tooling de editor: extensión VSCode con highlighting +
diagnostics + hover + go-to-definition + autocomplete contextual
via LSP, con distribución multi-platform. Errores subrayados al
tipear, mouse sobre una expresión muestra su tipo, F12 te lleva
a su declaración, tras
.aparecen métodos del tipo..vsixper-plataforma con el binario bundleado adentro víanpm run build:vsix. Sin ir a la terminal. Ver cap 22 para cómo instalar. - Formato canónico con
fitz fmt: cero config, estilo gofmt, preserva comments y blank lines del usuario. Modo--checkpara CI / pre-commit hooks. Ver cap 23. - Tests built-in con
@test+fitz test: decorator@testsobre fns sin args, 4 assertion builtins (assert,assert_eq,assert_ne,assert_throws), runner con output estilo cargo (ok/FAILED + summary + exit code), filtrado por substring, async tests, discovery automático en manifest mode (tests/*.fitz+[lib]integration). Cero librerías. Ver cap 24. - Hot reload con
fitz dev: file watcher sobre el proyecto + kill/respawn del child al detectar cambio en.fitzofitz.toml. Debounce 100ms, exclusión detarget//.git//node_modules/, banner ANSI entre runs, Ctrl+C atrapa sin dejar zombies. Ver cap 25. - REPL interactivo con
fitz repl: promptfitz>con env compartido entre líneas, multi-line automático (...), pretty-print Python-style del último valor, 5 comandos especiales (:help/:env/:type/:reset/:load/:quit), history persistente en~/.fitz/historycon arrow up/down + Ctrl+R, async transparente (sleep(100).awaitfunciona). Ver cap 26. - Linter con
fitz lint: 4 lints —unused_variable,unused_import,useless_match,string_concat. Default warning + exit 0;--deny <lint>promueve a error + exit 1 para CI. Supresión con// @allow(<lint>)en la línea anterior. Output estilo cargo-clippy con colores ANSI auto. Ver cap 27. - Auth nativa: tres decoradores —
@auth_provider(singleton del programa que recibe headers y devuelveResult<User>),@authenticated(handler protegido por bearer JWT; 401 automático),@admin(shorthand de auth +user.role == "admin"; 403 automático) — más dos módulos built-injwt(encode/decode HS256/384/512) yhash(Argon2id password hashing). El checker valida en compile-time que cada handler protegido tenga el provider registrado y reciba elUsercorrecto. El schema OpenAPI 3.1 auto-agregasecuritySchemes.bearerAuth+securitypor handler + 401/ 403 en responses. Paridad bit-a-bitfitz run↔fitz build. Cerocargo add/pip install. Ver cap 28. - WebSockets tipados:
@ws("/path")sobreasync fn+WsConn<T>conrecv/send/broadcast/close. Marshaling JSON automático de cada frame text altypedeclarado, AsyncAPI 3.0 auto-generado en/asyncapi.json, heartbeat built-in con@server(ws_heartbeat_secs=N), auth integrada en el handshake (@authenticated/@adminapilados ANTES del upgrade), codegen con paridad bit-a-bitfitz run↔fitz build. Ningún otro lenguaje hoy combina WS tipados con AsyncAPI auto-generado del código fuente. Ver cap 29. - Jobs sin Celery: tres piezas —
@cron("expr")para tareas periódicas (⅚/7 fields),@backgroundcomo marcador opt-in para autorizarspawn(...), yspawn(fn_call)fire-and-forget que devuelveFuture<T>tipado. Sin Celery, sin Redis, sin systemd timers — todo en el mismo binario. El checker valida en compile-time quespawn(...)apunte a una fn@backgroundy refina el ret type. Cron-only mode (sin@server) queda vivo bloqueante consignal::ctrl_c(modo systemd-friendly). Paridad bit-a-bitfitz run↔fitz buildconcron = "0.12"linkeado en el binario. Ver cap 30.
Es decir: todo lo que el intérprete de Fitz hoy ejecuta end-to-end, con un chequeo estático que atrapa errores antes de que se ejecuten, un compilador que produce binarios standalone, y un puente al ecosistema Python para usar SQLAlchemy/numpy/asyncpg sin abandonar Fitz.
Lo que viene¶
La promesa "Fitz usable para proyectos reales" ya está cumplida con todo el stack del lenguaje + ecosistema:
- Type checker estático, codegen binario nativo, async nativo, DX HTTP con OpenAPI 3.1 + UI Scalar, middleware + CORS, Send + paralelismo HTTP real.
- Interop Python entera: embedding, marshaling compuesto
bidireccional, excepciones →
Result<T>, coerción runtime,fitz py-typesSQLAlchemy, bridge tokio ↔ asyncio, codegen interop enfitz build. - Bundling Python standalone:
fitz build --bundle-pythonproduce binario con CPython 3.14.5 embebido (~22-35 MB según OS);--bundle-pip-requirementsagrega los paquetes pip delrequirements.txt. Launcher contar+flate2inline habilita runtimegcr.io/distroless/cc-debian12(smoke real Docker validado, imagen ~136 MB con sqlalchemy + Postgres). - Ecosistema completo: LSP MVP (ver cap 22), package manager
(cap 16b), DX (
fmt/test/dev/repl/lint, caps 23-27), stack web first-class (auth/WS/jobs, caps 28-30),envbuiltin (cap 32). - Stack DB nativo + ORM declarativo (cap 31). Driver Postgres
en Fitz puro, ORM sobre
typecon decoradores@table/@primary/@column/@belongs_to/@has_many/@has_one. SQL constante en codegen-time, paridad bit-a-bitfitz run↔fitz build.
Lo que viene (próximos nortes grandes):
- Frontend en
.fitz(SFC + SSR, especulativo): single-file components estilo Vue, WASM/JS targets, SSR built-in, sharing detypeentre back y front. Resuelve el doble tipado. - Deployment ciudadano primera clase:
fitz deploy, Dockerfile autogenerado, observability nativa (@trace/@metric), secrets management, feature flags built-in. - Migraciones automáticas para el ORM:
fitz db diff/migratebasado en el diff entre el shape declarado entypey el real en Postgres.
Ver docs/roadmap.md y docs/deudas-post-5b.md para el detalle.
Cómo va a crecer esta guía¶
La regla es estricta: un capítulo nuevo solo cuando lo que cubre funciona end-to-end en el intérprete. Si una feature está a medias, no entra todavía. Mejor decir "no se puede" que prometer algo que va a romper a quien lo lea.
Cada vez que se cierre un grupo de features (típicamente al cerrar una sub-fase del roadmap), la guía gana un capítulo o varios.
Recursos¶
- README.md — presentación pública del proyecto.
- docs/vision.md — el "por qué" y para quién.
- docs/syntax-spec.md — especificación completa de sintaxis. Incluye cosas que todavía no funcionan; tomalo como dirección, no como contrato.
- docs/roadmap.md — fases de desarrollo con el estado actual.
- docs/design-decisions.md — por qué Fitz toma ciertas decisiones (sin excepciones, HTTP nativo, etc.).
- docs/references.md — qué inspira a Fitz y de dónde sacar más contexto.
Reportar y contribuir¶
Si encontrás:
- Un ejemplo de la guía que no corre → es un bug. Reportalo.
- Un mensaje de error confuso → vale el reporte; mejorar mensajes es parte del trabajo de cada fase.
- Una sección poco clara → contame qué te confundió. La guía es joven, todo feedback ayuda.
El repo principal está en GitHub: github.com/Thegreekman76/fitz. Si querés contribuir con código, la mejor manera hoy es revisar el roadmap, elegir algo pendiente y abrir un issue proponiéndolo antes de empezar.
Eso es todo por esta primera versión. Si Fitz te resultó interesante y querés mantenerte cerca del proyecto, mirá los commits del repo: cada feature nueva trae sus tests y, cuando aplique, una actualización de esta guía.
Nos vemos en el próximo capítulo. 🏔️