Biblioteca estándar
Archivos e I/O
Todo acceso a archivos está gateado por la capacidad file (file.read / file.write para mínimo privilegio). Las escrituras son atómicas (archivo temporal + rename).
Un scope es un glob por segmento (engine v0.6.45+): file.read("./data/") son los archivos que están directamente en ./data; file.read("./data/*") es ./data misma y todo lo que hay abajo, a cualquier profundidad. Las reglas completas — .., links, el disco entero — están en Capacidades.
-- Doc example: files & I/O, and the file formats (CSV, Parquet, JSON Lines). Needs the file capability (deny-by-default).
intent: "doc example: files and I/O"
require file("_doctest_io.txt")
require file("_doctest_io.parquet")
require file("_doctest_io.jsonl")
require file.write("_doctest_io_gone.txt")
require file.read(".") -- cwd() needs exactly this: the same grant list_dir(".") needs
require file("./_doctest_tree/**") -- `**`: the folder, its files AND every subfolder (v0.6.45+)
require file.read("./_doctest_flat/*") -- `*`: only the files directly inside, one level
write_file("_doctest_io.txt", "hi from the io demo")
print("read back → " + read_file("_doctest_io.txt"))
test "delete_file needs file.write on the path and is honest about what happened (engine v0.6.20+)"
write_file("_doctest_io_gone.txt", "bye")
assert_eq(delete_file("_doctest_io_gone.txt"), true)
assert_error(() => delete_file("_doctest_io_gone.txt")) -- already gone → error, not false
test "cwd() is the real working directory, normalized to forward slashes (engine v0.6.20+)"
assert_eq(type_of(cwd()), "text")
assert(length(cwd()) > 0)
assert_eq(contains(cwd(), "\\"), false)
test "write then read round-trips (atomic write)"
write_file("_doctest_io.txt", "hello\nworld")
assert_eq(read_file("_doctest_io.txt"), "hello\nworld")
assert(file_exists("_doctest_io.txt"))
assert_eq(keys(file_info("_doctest_io.txt")), ["exists", "is_dir", "size", "modified", "is_link"])
assert_eq(file_info("_doctest_io.txt").size, 11)
test "path scopes are globs by segment: `*` stays in one folder, `**` crosses them (v0.6.45+)"
when file_exists("_doctest_tree")
delete_dir("_doctest_tree", {"recursive": true}) -- a fresh tree for this test
write_file("_doctest_tree/top.txt", "top")
write_file("_doctest_tree/sub/deep.txt", "deep")
assert_eq(read_file("_doctest_tree/sub/deep.txt"), "deep") -- `**` reaches every level
assert_eq(length(list_dir("_doctest_tree")), 2) -- ... and the folder itself
assert_error(() => read_file("_doctest_tree/../_doctest_x.txt")) -- no wildcard matches `..`
assert_error(() => read_file("_doctest_flat/sub/x.txt")) -- `*` is one level: denied
assert_error(() => list_dir("_doctest_flat")) -- `./x/*` names the files, not the folder
test "grep on a folder reads the tree, so it needs `<folder>/**`; edit_file reads before it writes"
write_file("_doctest_tree/sub/deep.txt", "needle here")
let g be grep("_doctest_tree", "needle")
assert_eq(apply(g.matches, (m) => m.file), ["_doctest_tree/sub/deep.txt"])
assert_eq(edit_file("_doctest_tree/sub/deep.txt", "needle", "pin"), {"replaced": 1}) -- file.read + file.write
assert_eq(read_file("_doctest_tree/sub/deep.txt"), "pin here")
delete_dir("_doctest_tree", {"recursive": true})
task why_not(path)
try
read_file(path)
recover e
give e
give "read"
test "read_file says why it cannot read (v0.6.45+): File not found is only for what does not exist"
write_file("_doctest_tree/bin.dat", bytes([255, 254, 0]))
write_file("_doctest_tree/sub/deep.txt", "deep")
assert(starts_with(why_not("_doctest_tree/nope.txt"), "File not found"))
assert(contains(why_not("_doctest_tree/sub"), "it is a directory (use list_dir)"))
assert(contains(why_not("_doctest_tree/bin.dat"), "not valid UTF-8 text (use read_file_bytes)"))
assert_eq(read_file_bytes("_doctest_tree/bin.dat"), bytes([255, 254, 0]))
delete_dir("_doctest_tree", {"recursive": true})
test "gzip / gunzip: bytes in, bytes out; deterministic; a bomb is stopped (v0.6.45+)"
let z be gzip("hello hello hello") -- text or bytes in, bytes out
assert_eq(decode(gunzip(z), "utf8"), "hello hello hello")
assert_eq(gzip("same"), gzip("same")) -- no name or time in the header
assert_eq(decode(gunzip(z + gzip(" world")), "utf8"), "hello hello hello world") -- concatenated members
assert_error(() => gunzip(gzip("aaaaaaaaaaaaaaaaaaaa"), {"max_bytes": 10}))
test "csv_parse: an empty field is nothing, a quoted \"\" is the empty text, untyped text stays lossless"
assert_eq(csv_parse("a,b\n007,\n"), [{"a": "007", "b": nothing}])
assert_eq(csv_parse("a,b\n\"\",\n"), [{"a": "", "b": nothing}])
assert_eq(csv_parse("x\n1\n\n2\n"), [{"x": "1"}, {"x": "2"}]) -- a blank line is skipped
assert_eq(csv_parse("a;b\n1;2\n", {"delimiter": ";"}), [{"a": "1", "b": "2"}])
assert_eq(csv_parse("1,2\n3,4\n", {"headers": false}), [["1", "2"], ["3", "4"]])
assert_eq(csv_parse("a,b\n007,2\n", {"numbers": true}), [{"a": 7, "b": 2}]) -- the old guess: ids lose zeros
test "csv_parse types: int, float, decimal (exponent too), text, bool, date, datetime"
let rows be csv_parse("a,b,c,d,e,f,g\n007,1.5,1.5e-3,x,yes,2026-01-02,2026-01-03T10:00:00\n", {"types": {"a": "int", "b": "float", "c": "decimal", "d": "text", "e": "bool", "f": "date", "g": "datetime"}})
assert_eq(rows[0].a, 7)
assert_eq(rows[0].b, 1.5)
assert_eq(rows[0].c, 0.0015d)
assert_eq(rows[0].d, "x")
assert_eq(rows[0].e, true)
assert_eq(rows[0].f, date(2026, 1, 2))
assert_eq(rows[0].g, datetime("2026-01-03T10:00:00Z")) -- no zone → UTC
assert_eq(csv_parse("a,b\n,1\n", {"types": {"a": "int"}}), [{"a": nothing, "b": "1"}]) -- empty is nothing, typed or not
let msg be ""
try
csv_parse("a\nx\n", {"types": {"a": "int"}})
recover e
set msg to e
assert(contains(msg, "line 2") and contains(msg, "column \"a\""))
assert_error(() => csv_parse("a\n1e400\n", {"types": {"a": "float"}})) -- never infinity
assert(is_nan(csv_parse("a\nnan\n", {"types": {"a": "float"}})[0].a))
assert_error(() => csv_parse("a\n1\n", {"types": {"zz": "int"}})) -- not in the header
assert_error(() => csv_parse("a,b\n1\n")) -- uneven fields
assert_error(() => csv_parse("a\n1\n", {"delimiter": "\""}))
test "missing marks: {missing: [...]} reads them as nothing; csv_encode writes them back"
assert_eq(csv_parse("a,b\nNA,1\n\"NA\",2\n", {"missing": ["NA"]}), [{"a": nothing, "b": "1"}, {"a": "NA", "b": "2"}])
let rows be [{"a": nothing, "b": "NA"}, {"a": "x", "b": nothing}]
assert_eq(csv_encode(rows, {"missing": "NA", "eol": "\n"}), "a,b\nNA,\"NA\"\nx,NA\n")
assert_eq(csv_parse(csv_encode(rows, {"missing": "NA"}), {"missing": ["NA"]}), rows)
assert_error(() => csv_encode(rows, {"missing": ""}))
test "csv_encode: nothing and empty text apart, dates as ISO, headers/eol, escape_formulas"
assert_eq(csv_encode([{"a": "", "b": nothing}], {"eol": "\n"}), "a,b\n\"\",\n")
let tricky be [{"a": "", "b": nothing, "c": "x,y"}]
assert_eq(csv_parse(csv_encode(tricky)), tricky)
assert_eq(csv_encode([{"d": date(2026, 1, 31), "u": duration(hours = 1)}], {"eol": "\n"}), "d,u\n2026-01-31,PT1H\n")
assert_eq(csv_encode([{"a": "1", "b": "2"}], {"headers": ["b"], "eol": "\n"}), "b\n2\n")
assert_eq(csv_encode([["x", "y"]], {"delimiter": ";", "eol": "\n"}), "x;y\n")
assert_eq(csv_encode([{"f": "=SUM(A1)", "n": -5}], {"escape_formulas": true, "eol": "\n"}), "f,n\n'=SUM(A1),-5\n")
assert_error(() => csv_encode([{"a": [1, 2]}])) -- nested: json_encode that field
assert_eq(csv_encode([{"b": bytes("Hi")}], {"eol": "\n"}), "b\nSGk=\n")
assert_eq(csv_encode([{"a": nothing}], {"eol": "\n"}), "a\n\"\"\n") -- one column: nothing is written "" (warns once)
assert_eq(csv_parse(csv_encode([{"a": nothing}]), {"types": {"a": "int"}}), [{"a": nothing}])
assert_error(() => csv_parse("a,a\n1,2\n")) -- duplicate headers
assert_error(() => csv_parse("a\n\"open\n")) -- unclosed quote
test "parquet: typed columns round-trip through a file"
let rows be [
{"id": 1, "x": 1.5, "p": 1.50d, "s": "a", "ok": true, "b": bytes("hi"), "d": date(2026, 1, 3), "t": datetime("2026-01-03T10:00:00", "Europe/Madrid"), "u": duration(minutes = 90), "n": nothing},
{"id": 2, "x": 2.0, "p": 10.25d, "s": "b", "ok": false, "b": bytes("yo"), "d": date(2026, 1, 4), "t": datetime("2026-01-04T10:00:00", "Europe/Madrid"), "u": duration(seconds = 1), "n": nothing}]
write_file("_doctest_io.parquet", parquet_write(rows, {"compression": "zstd"}))
let back be parquet_read(read_file_bytes("_doctest_io.parquet"))
assert_eq(back, rows)
assert_eq(text(back[0].t), "2026-01-03T10:00:00+01:00[Europe/Madrid]") -- the zone comes back
assert_eq(type_of(back[0].u), "duration")
each c in ["snappy", "gzip", "lz4", "none"]
assert_eq(parquet_read(parquet_write(rows, {"compression": c})), rows)
let f be decode(parquet_write(rows), "utf8_lossy")
assert(contains(f, "ARROW:schema")) -- pandas / pyarrow / polars read the types
assert_eq(text(parquet_read(parquet_write([{"t": datetime("2026-01-03T14:30:00+05:30")}]))[0].t), "2026-01-03T14:30:00+05:30")
assert_eq(parquet_read(parquet_write([{"t": datetime("2026-01-03T10:00:00.123456789Z")}]))[0].t, datetime("2026-01-03T10:00:00.123456789Z"))
assert_eq(parquet_read(parquet_write([{"a": 1}, {"a": 2.5}])), [{"a": 1.0}, {"a": 2.5}]) -- int + float → DOUBLE
assert_eq(parquet_read(parquet_write([{"a": 1}, {"b": 2}])), [{"a": 1, "b": nothing}, {"a": nothing, "b": 2}])
test "parquet: errors instead of silent loss"
assert_error(() => parquet_write([{"a": 1}, {"a": "x"}])) -- one type per column
assert_error(() => parquet_write([{"a": [1, 2]}])) -- nested: json_encode it first
assert_error(() => parquet_write([{"a": 1.5}, {"a": 9007199254740993}])) -- a DOUBLE would round it
assert_error(() => parquet_write([{"a": 2 ** 70}])) -- beyond 64 bits
let msg be ""
try
parquet_write([{"t": datetime("2026-01-03T10:00:00.123456789Z")}, {"t": datetime("2300-01-01T00:00:00Z")}])
recover e
set msg to e
assert(contains(msg, "column \"t\"")) -- nanoseconds + a far date: one unit cannot hold both
assert_error(() => parquet_write([{"a": 1}], {"compression": "brotli"}))
assert_error(() => parquet_read(bytes("not parquet")))
let small be parquet_write([{"a": 1, "b": 2}, {"a": 3, "b": 4}])
assert_error(() => parquet_read(small, {"max_cells": 3})) -- 4 cells > 3
assert_eq(length(parquet_read(small, {"max_cells": 4, "max_bytes": 1000000})), 2)
assert_error(() => parquet_read(small, {"bogus": 1}))
test "parquet: read a part of a file, or only its shape (v0.6.45+)"
let p be parquet_write([{"a": 1, "b": "x"}, {"a": 2, "b": "y"}, {"a": 3, "b": "z"}])
let info be parquet_info(p) -- no data is read
assert_eq(info.rows, 3)
assert_eq(info.columns, [{"name": "a", "type": "int64"}, {"name": "b", "type": "string"}])
assert_eq(length(info.row_groups), 1)
assert_eq(parquet_read(p, {"columns": ["a"], "limit": 2}), [{"a": 1}, {"a": 2}])
assert_eq(length(parquet_read(p, {"row_groups": [0]})), 3)
assert_error(() => parquet_read(p, {"columns": ["zz"]})) -- "the file has no column \"zz\" (columns: a, b)"
test "parquet_scan reads the part it is asked for straight from the disk (v0.6.46+)"
write_file("_doctest_io.parquet", parquet_write([{"a": 1, "b": "x"}, {"a": 2, "b": "y"}, {"a": 3, "b": "z"}]))
assert_eq(parquet_scan("_doctest_io.parquet", {"columns": ["a"], "limit": 2}), [{"a": 1}, {"a": 2}])
assert_eq(keys(parquet_scan("_doctest_io.parquet", {"columns": ["b", "a"], "limit": 1})[0]), ["a", "b"]) -- the file's column order
assert_eq(parquet_scan("_doctest_io.parquet"), parquet_read(read_file_bytes("_doctest_io.parquet")))
assert_eq(parquet_scan("_doctest_io.parquet", {"row_groups": []}), [])
let last be lineage()[-1] -- an input with the path, hashed as canonical_json
assert_eq(last.source, "parquet_scan")
assert_eq(last.what, "_doctest_io.parquet")
assert_eq(last.encoding, "jcs")
assert_eq(last.sha256, decode(sha256(canonical_json([])), "hex"))
assert_error(() => parquet_scan(read_file_bytes("_doctest_io.parquet"))) -- bytes: that is parquet_read
assert_error(() => parquet_scan("_doctest_io.parquet", {"row_groups": [1]}))
assert_error(() => parquet_scan("_doctest_io.parquet", {"limit": 0}))
assert_error(() => parquet_scan("_doctest_io.parquet", {"bogus": 1}))
assert_error(() => parquet_scan("_doctest_not_granted.parquet")) -- needs file.read on the path
test "JSON Lines files"
write_file("_doctest_io.jsonl", jsonl_encode([{"ev": "a"}, {"ev": "b"}]))
assert_eq(collect(jsonl_decode(read_file("_doctest_io.jsonl")), "ev"), ["a", "b"])Leer§
read_file(path) -- whole file as text
read_file(path, 1, 100) -- a line range: lines 1–100 (1-based, EOLs preserved)
read_file(path, 500) -- from line 500 to EOF
read_file_bytes(path) -- byte-exact, for binary
read_file dice por qué no puede leer un archivo (engine v0.6.45+). File not found: <path> es solo para una ruta que no existe; todo lo demás nombra su causa:
| El error dice | Qué significa |
|---|---|
… it is a directory (use list_dir) | la ruta es una carpeta |
… it is not valid UTF-8 text (use read_file_bytes) | un archivo binario, o texto en otra codificación |
… in use by another process … retry later | Windows: otro proceso tiene el archivo abierto sin compartirlo |
| un link roto, un permiso | eso, con su nombre |
read_file_bytes y edit_file dan las mismas respuestas.
Escribir y editar§
write_file(path, text) -- atomic overwrite
append_file(path, text) -- append
edit_file(path, old, new) -- exact-string replace (old must be unique; replace_all? for all)
edit_file necesita file.read además de file.write sobre la ruta (engine v0.6.45+): lee el archivo para encontrar el texto, y sus respuestas (no encontrado, ambiguo, cuántos reemplazó) dicen qué hay adentro. require file("./notes/**") da las dos.
Navegar y buscar§
file_exists(path) -- bool
file_info(path) -- {exists, is_dir, size, modified, is_link}
list_dir(path) -- list of {name, is_dir, size, is_link}
grep(target, pattern, opts?) -- {matches: [{file, line, col, text}], truncated} — streams per line
grepsobre una carpeta necesitafile.read("<carpeta>/") (engine v0.6.45+): lee el árbol entero, yfile.read("./proj")es la carpeta sola. Sin eso:Capability not granted: file_read("proj/"). No sigue links a carpetas (los saltea).is_link(engine v0.6.45+) marca un symlink o una junction. Elis_dirde un link es el de lo que apunta, igual enlist_diry enfile_info; enlist_direlsizede un link es0(preguntale afile_info, que responde solo cuando el link lleva adentro de tu grant — uno que lleva afuera se rechaza, ver Capacidades).
Borrar (engine v0.6.20+)§
delete_file(path) -- true; a missing file (or a directory) is an error, never a silent false
delete_dir(path) -- empty directories only — "is not empty (pass {\"recursive\": true} …)"
delete_dir(path, {"recursive": true}) -- the whole tree
Borrar es escribir: delete_file necesita file.write sobre esa ruta; delete_dir la necesita sobre el directorio y, en recursivo, sobre cada ruta de adentro — la misma grant que te dejaría borrarlas una por una. Con file.write("./tmp") sola, un borrado recursivo de ./tmp se rechaza en el primer hijo (file_write("tmp/k")) y no se borra nada; file.write("./tmp/**") cubre la carpeta y todo lo que tiene abajo. Se chequean todas las rutas antes de borrar la primera. Una ruta dentro del bundle de synsema build es de solo lectura, así que borrarla se rechaza.
Dónde estoy — cwd(), ~ (engine v0.6.20+)§
cwd() devuelve el directorio de trabajo real, normalizado con barras (C:/Users/me/proj), y necesita file.read(".") — la misma grant que necesita list_dir("."); file.read("./") y file.read("") también la cubren. file.read("./") y file.read("") no (engine v0.6.45+): nombran los archivos de la carpeta, no la carpeta. La ruta absoluta es información del host (nombre de usuario, disposición del disco), así que bajo --sandbox no es gratis. En un binario de synsema build es el directorio desde donde se lanzó el binario, nunca el bundle.
~ y ~/… expanden al home (HOME, si no USERPROFILE) en las rutas y en los scopes: require file.read("~/.config/app/*") y después read_file("~/.config/app/x.toml"). ~usuario/ no está soportado (error claro).
Los assets del programa vs. los archivos del usuario — bundle: / disk:§
En un binario hecho con synsema build, una ruta que está en el bundle es parte del programa: read_file, read_file_bytes, file_exists, file_info y grep la leen del bundle, sin capacidad file y con una línea de audit que lo dice (bundled asset (part of the program)); un archivo con el mismo nombre relativo en el directorio de trabajo jamás lo tapa. Una ruta que no está en el bundle es del usuario y va al disco con file.read, como siempre. Dos prefijos lo hacen explícito: read_file("bundle:data.txt") fuerza el bundle (error si no está), read_file("disk:data.txt") fuerza el disco (necesita file.read). list_dir(path) lista solo el disco; list_dir("bundle:") y list_dir("bundle:sub/") listan el bundle.
Archivos comprimidos — zip y tar (engine v0.6.20+)§
let z be zip_create([{"path": "a.txt", "bytes": bytes("hi")},
{"path": "dir/hello.txt", "from": "./data/hello.txt"}]) -- bytes
zip_extract(z, "./out") -- ["a.txt", "dir/hello.txt"] — what was written
let t be tar_create(entries, {"gzip": true}) -- .tar.gz bytes
tar_extract(t, "./out2") -- gzip detected by its magic bytes
entriesson{"path", "bytes"}o{"path", "from": <archivo>};fromnecesitafile.read(un asset del bundle, ninguna). Los paths dentro del archivo se normalizan a/;..y los absolutos se rechazan al crear. Determinista: las mismas entradas dan archivos byte a byte idénticos (timestamps fijos).- Extraer necesita
file.writesobre cada ruta que escribe — la misma grant que pediríawrite_filepara esas rutas — así quefile.write("./out/*")extrae en./out(se crea si falta) con sus subcarpetas;file.write("./out/")cubre solo las entradas de arriba (dir/hello.txtse rechaza:Capability not granted: file_write("out/dir/hello.txt")), y un scope exacto sobre./outsolo se rechaza en la primera entrada. Zip-slip rechazado: una entrada que caería fuera del destino (../evil.txt) es error y no se escribe nada. Los symlinks y hard links del archivo se omiten. Desde el motor v0.6.46 cada entrada se escribe comowrite_file: atómicamente (sin archivo a medias si falla), y cuando un link en disco ya tiene el nombre de la entrada, el link se reemplaza por el archivo en vez de escribirse a través de él (v0.6.45 fallaba ahí conAcceso denegadosobre una junction). - Bombas:
opts.max_entries(default 10 000) yopts.max_bytes(default 512 MB) cuentan los bytes reales descomprimidos, nunca el tamaño que declara el header; al pasarse, error y nada escrito. El precio de "nada escrito" es que el contenido se retiene en memoria (hastamax_bytes) antes de que caiga el primer archivo — bajá el techo o extraé por partes si el archivo es enorme. - Solo nativo: bajo
--profile pure(y en los builds wasm) los cuatro son stubs honestos (not available in the pure profile)..gitignoreno se interpreta — lasentrieslas armás vos.
gzip — gzip / gunzip (engine v0.6.45+)§
Para archivos .json.gz / .csv.gz sueltos (un .tar.gz es tar_extract):
require file.read("./in/*")
require file.write("./out/*")
let raw be gunzip(read_file_bytes("./in/events.json.gz")) -- bytes
let rows be json_decode(decode(raw, "utf8"))
write_file("./out/rows.json.gz", gzip(json_encode(rows))) -- text or bytes in, bytes out
let big be gunzip(read_file_bytes("./in/dump.csv.gz"), {"max_bytes": 2147483648}) -- raise the cap on purpose
gzip(data)toma texto o bytes y da bytes. Es determinista: sin nombre de archivo ni hora en el header, así que la misma entrada da los mismos bytes.gunzip(bytes, opts?)da bytes (decode(raw, "utf8")para texto). Lee miembros concatenados (cat a.gz b.gz > ab.gz), comogzip -d.- Una bomba se frena: por default se niega a producir más que lo mayor entre 256 MiB y 64 × la entrada, con
gunzip: the uncompressed data is larger than max_bytes (…); raise it with {"max_bytes": n} if you expect that much. - Puras: también bajo
--profile purey en el build wasm. El archivo en sí esread_file_bytes/write_file, con su propia capacidad.
Formatos de archivo — CSV, Parquet, JSON Lines§
Los builtins de formato son puros: entra texto o bytes, salen valores. El disco es read_file / read_file_bytes / write_file, con su propia capacidad file. Todos producen o toman una tabla — una lista de maps, la misma forma que devuelve sql() — que es sobre lo que trabaja Análisis de datos.
require file.read("./data/*")
require file.write("./out/*")
let sales be csv_parse(read_file("./data/sales.csv"), {"types": {"day": "date", "amount": "decimal"}})
let events be jsonl_decode(read_file("./data/events.jsonl"))
let orders be parquet_read(read_file_bytes("./data/orders.parquet"))
write_file("./out/sales.parquet", parquet_write(sales, {"compression": "zstd"}))
CSV — csv_parse / csv_encode§
csv_parse(text, opts?) → una lista de maps (primera fila = cabeceras). RFC 4180 completo: campos entre comillas, delimitadores, comillas y saltos de línea embebidos, escapes "", CRLF/LF, un BOM UTF-8.
- Un campo vacío es
nothing— un dato faltante — con tipo o sin él. Un campo vacío entre comillas""es el texto vacío. Una línea en blanco siempre se saltea. - Sin tipos, cada campo queda texto, sin pérdida (
"007"sigue siendo"007"). - Un campo que no encaja en su tipo es un error con la línea y la columna (
csv_parse: line 2, column "a": "x" is not an int). También lo son una comilla sin cerrar, campos desparejos, cabeceras duplicadas y una opción desconocida — todos atrapables contry/recover.
| Opción | Significado |
|---|---|
"types" | {"col": tipo} por columna — la forma recomendada. "int" (exacto: "007" → 7), "float" (lee nan/inf; 1e400 es un error, nunca infinito), "decimal" (exacto, cualquier cantidad de dígitos, con exponente: 1.5e-3 → 0.0015), "text", "bool" (true/false/1/0/yes/no, en cualquier caja), "date" (YYYY-MM-DD), "datetime" (ISO 8601; sin zona → UTC). Las columnas sin nombrar quedan texto; nombrar una columna que la cabecera no tiene es un error |
"missing" | una lista de textos que significan faltante, como ["NA", "NULL", "-"] (el null_values de polars, el na_values de pandas): esos textos sin comillas son nothing; un "NA" entre comillas queda texto |
"headers" | false → una lista de listas (todas las filas son datos) |
"delimiter" | un carácter, p. ej. ";" o "\t" (no la comilla ni un fin de línea) |
"numbers" | true → la adivinanza vieja: todo campo con pinta numérica se vuelve número (los ids y códigos postales pierden sus ceros). Preferí types |
csv_encode(rows, opts?) → texto. Las filas son una lista de maps (cabeceras = las claves del primer map, en su orden) o una lista de listas. Enteros sin decimales; nothing → un campo vacío y el texto vacío → "", así que csv_parse(csv_encode(rows)) == rows; fechas, datetimes y durations → su texto ISO; bytes → base64; un secret → un error (motor v0.6.46+: csv_encode: row 1, column "k": secret(K) is never written as text — leave it out of the rows; antes salía como [redacted]); una lista o map anidado → un error que sugiere json_encode para ese campo.
| Opción | Significado |
|---|---|
"headers" | una lista de nombres de columna → el orden y el subconjunto de columnas |
"delimiter" | como en csv_parse |
"eol" | "\r\n" (default, compatible con Excel) o "\n" |
"missing" | una marca que se escribe por cada nothing, sin comillas, como "NA" (el na_rep de pandas); un texto igual a la marca va entre comillas. Leelo de vuelta con {"missing": ["NA"]} para un ida y vuelta exacto. Una marca vacía es un error |
"escape_formulas" | true → una celda de texto que empieza con =, +, -, @, tab o CR lleva un ' adelante, así Excel y Sheets la muestran en vez de ejecutarla (la "CSV injection" de OWASP). Los números no se tocan. Apagado por defecto, porque cambia los datos — prendelo en todo CSV que una persona abra en una planilla con texto que vino de usuarios |
Un caso borde: en un CSV de una sola columna, una fila cuyo único campo es nothing sería una línea en blanco, que la lectura saltea, así que se escribe "" (como hace el writer de Python) y vuelve como texto vacío — o nothing si leés esa columna con tipo; csv_encode avisa una vez por stderr. La opción "missing" vuelve exacto ese ida y vuelta también.
Parquet — parquet_read / parquet_write§
Archivos columnares como los que escriben polars, pandas, Spark y DuckDB. Entran bytes, salen bytes. Solo en builds nativos (no en el build wasm).
parquet_read(bytes, opts?) → filas tipadas: Int64 → entero exacto, Double/Float → float, Decimal → decimal, String → texto, Binary → bytes, Bool → bool, Date → date, Timestamp → datetime (UTC, o la zona de la columna — la que guardó Synsema, o la que escribió polars/pyarrow, offsets fijos como +05:30 incluidos; mili-, micro- y nanosegundos), Duration → duration, Time (hora del día) → una duration desde medianoche, listas y grupos → listas y maps anidados, null → nothing. Compresión zstd, snappy, gzip y lz4. Un archivo que no es Parquet, o que tiene dos columnas con el mismo nombre, es un error.
- Topes de expansión: un archivo chico no puede hacer que el engine reserve gigabytes. Rechaza, antes de reservar, una página de más de 256 MiB sin comprimir, un archivo que se expande más allá de
max_bytes(default: lo mayor entre 1 GiB y 64 × el archivo) o con más demax_cells= filas × columnas (default 50 000 000). Subilos a propósito para un archivo grande en el que confiás:parquet_read(b, {"max_cells": 200000000, "max_bytes": 8589934592}). Cualquier otra opción es un error. - Leer una parte (engine v0.6.45+):
parquet_read(b, {"columns": ["id", "amount"], "row_groups": [0, 2], "limit": 1000})lee solo esas columnas, esos row groups (contados desde 0) y como mucho esa cantidad de filas; los topes de arriba cuentan solo lo que se lee, y la entrada se copia una sola vez. Una columna que el archivo no tiene es un error que lista las que sí tiene (parquet_read: the file has no column "zz" (columns: a, b)). - La forma sin los datos:
parquet_info(bytes)→{rows, columns: [{name, type}], row_groups: [{rows, bytes}]}— decidí qué leer antes de leerlo.
#### parquet_scan — una parte de un archivo, directo del disco (engine v0.6.46+)
parquet_read recibe los bytes del archivo, así que primero se lee el archivo entero a memoria. parquet_scan(path, opts?) lee el archivo en disco, por partes: el pie con los metadatos y después solo las páginas de las columnas y los row groups que se le piden. Un archivo de gigas con una columna pedida lee esa columna.
require file.read("./data/**")
let few be parquet_scan("./data/events.parquet", {"columns": ["id", "kind"], "limit": 1000})
let first_group be parquet_scan("./data/events.parquet", {"row_groups": [0]})
let everything be parquet_scan("./data/events.parquet")
- Opciones: las mismas que
parquet_read, con los mismos chequeos.columns— una lista de nombres; las filas conservan el orden de columnas del archivo, en el orden que sea que las pidas;[]da un map vacío por fila.row_groups— índices desde 0;[]da[].limit— un entero positivo (0,-1o1.5son un error).max_cells(default 50 000 000 = filas × columnas leídas) ymax_bytes(default lo mayor entre 1 GiB y 64 × el tamaño del archivo), contados solo sobre lo que se lee y chequeados antes de reservar memoria. Una página que declara más de 256 MiB se rechaza aun conlimit: ninguna opción sube ese tope. Sinoptslee el archivo entero. - Resultado: las mismas filas tipadas que da
parquet_read—parquet_scan(p)es igual aparquet_read(read_file_bytes(p)). - Capacidad:
require file.read(<la ruta>)(una carpeta:file.read("./data/**")); se chequea y se abre en un solo paso, comoread_file.synsema checknombra elrequireque falta. Un archivo empaquetado en un binario desynsema buildcon--includese lee del paquete, sin el permiso. - Linaje: cada llamada es una entrada
{source: "parquet_scan", what: <la ruta como se escribió>, sha256, bytes, encoding: "jcs"}, dondesha256es el hex desha256(canonical_json(rows))de las filas que devolvió ybytessu largo.parquet_read(b)registra solobytes <n>;parquet_scanregistra la ruta. - Con etiquetas, como
read_file: una llamada bajo una rama decidida por un valor privado es unlabel_violation. - Lo que no hace: no hay un iterador en streaming — las filas vuelven como una sola lista, así que acotá una lectura grande con
columns,row_groupsylimit.parquet_infosigue recibiendo bytes (una ruta es un error que lo dice). No está en el build wasm.
| Error | Arreglo |
|---|---|
parquet_scan: expected a path, got bytes — for bytes already in memory, parquet_read(bytes) | pasá la ruta, o usá parquet_read |
parquet_scan: the file has no column "zz" (columns: id, name, v) | un nombre de esa lista |
parquet_scan: row group 1 does not exist (the file has 1: 0..1) | un índice menor que la cantidad de parquet_info(…).row_groups |
parquet_scan: unknown option "bogus" (valid: columns, row_groups, limit, max_cells, max_bytes) | una de esas |
parquet_scan: option "limit" must be a positive integer, got 0 | un entero desde 1 |
parquet_scan: opts must be a map, got text | un map |
parquet_scan: the file has 30 cells (rows × columns); the limit is 5 — raise it on purpose with {"max_cells": n} | leé menos, o subilo para un archivo en el que confiás |
parquet_scan: the file expands to more than 10 bytes uncompressed (max_bytes) — raise it on purpose with {"max_bytes": n} | lo mismo |
parquet_scan: a page declares 2047 MiB uncompressed (the limit is 256 MiB per page; writers use about 1 MiB) — the file is malformed or hostile, refusing to allocate it | el archivo está roto o es hostil |
parquet_scan: not a Parquet file: … | no es Parquet |
parquet_scan: the file has two columns named "a" — a row is a map and would lose one; rename it where the file is written | reescribí el archivo con nombres distintos |
File not found: missing.parquet | la ruta |
`Capability not granted: file_read("other.parquet") — … add require file.read("other.parquet") …` | ese require |
parquet_write(rows, opts?) → bytes. Un esquema por columna, inferido, toda columna admite nulos: int → INT64, float → DOUBLE (una columna que mezcla ints y floats → DOUBLE), decimal → DECIMAL(38, la escala más grande de la columna), texto → STRING, bool → BOOLEAN, bytes → BINARY, date → DATE, datetime → TIMESTAMP en µs (ns cuando un valor tiene nanosegundos) con su zona, duration → INT64 en µs (o ns) con su unidad. Una clave que falta en una fila es nothing ahí. opts.compression = "snappy" (default), "zstd", "gzip", "lz4" o "none".
- Otras herramientas leen los tipos (engine v0.6.30+): el archivo lleva el esquema Arrow (
ARROW:schema), como lo escriben pyarrow y polars, así que pandas, pyarrow y polars leen una columnadurationcomo duración (timedelta64) y una columnadatetimecon su zona IANA (datetime64[us, Europe/Madrid]). Una zona de offset fijo (+05:30) va como UTC en ese esquema, porque polars no puede abrir un archivo con una; Synsema igual lee el offset de vuelta. - Ida y vuelta:
parquet_read(parquet_write(rows)) == rows, zonas, nanosegundos y durations incluidos. - Errores en vez de pérdida silenciosa: una columna que mezcla tipos (
column "a" mixes Int and Text); una lista o map anidado (json_encodeesa columna antes); un entero más allá de 2^53 en una columna que mezcla ints y floats (un DOUBLE lo redondearía — hacé la columna decimal o texto); un entero de más de 64 bits; y una columna donde un valor tiene nanosegundos y otro está más allá de lo que alcanzan los nanosegundos (un datetime fuera de 1677–2262, una duration de más de ±292 años) — una unidad por columna no puede con los dos, así que poné los valores lejanos en otra columna o guardala como texto (text(x)conserva todos los dígitos).
JSON Lines — jsonl_encode / jsonl_decode§
Un valor JSON por línea: jsonl_decode(read_file("events.jsonl")), write_file("out.jsonl", jsonl_encode(rows)). Las líneas en blanco se saltean y una línea mala es un error que nombra su número. El contrato completo (enteros grandes, NaN, forma con fallback) está en JSON, XML y TOML.
Los scopes de ruta son fieles — un escape con .. fuera del scope declarado se deniega, y un link (symlink o junction) debajo de la grant que lleva afuera se rechaza (engine v0.6.45+). Ver Capacidades.
Sin ventana entre el chequeo y la apertura (motor v0.6.46+, Linux y Windows). v0.6.45 chequeaba una ruta en disco y después la abría por nombre, así que una carpeta cambiada por un link en el medio se seguía afuera del permiso. Ahora el archivo se abre sin seguir ningún link (openat2 con RESOLVE_NO_SYMLINKS en Linux, NtCreateFile con OBJ_DONT_REPARSE en Windows): si abre, es el archivo que juzgó el chequeo. Con un link en el camino, se abre siguiéndolo pero sin acceso a sus datos, el mismo chequeo corre de nuevo sobre dónde está de verdad, y recién ahí se reabre el mismo archivo con el acceso pedido. Un link que queda dentro del permiso sigue funcionando. Para reemplazar un archivo (write_file, edit_file, zip_extract/tar_extract) la carpeta se abre igual, y el archivo temporal, el rename y la limpieza van todos relativos a ella, así el nombre nunca se vuelve a resolver.
- Cubre:
read_file(entero o un rango de líneas),read_file_bytes,edit_file,write_file,append_file,grep, los archivos dezip_create/tar_createy las entradas dezip_extract/tar_extract,mtls_identity, las fuentes desvg_to_png/svg_to_pdf,render(y suinclude/layout) y los archivos de una rutastatic. - No cubre (como en v0.6.45):
list_dir,file_exists,file_info,delete_file,delete_dir, las carpetas quewrite_filecrea cuando faltan, y los PEM detls cert … key …enserve; macOS, un kernel Linux sinopenat2(antes de 5.6) o un contenedor cuyo seccomp lo rechaza (Docker antes de 20.10), y las rutas UNC en Windows; y un hard link, que no se ve desde su nombre. - Los nombres de Windows se abren como están escritos.
a.txt.ya.txt(un punto o un espacio al final) ya no abrena.txt, y…/CONno abre el dispositivo de consola: son errores (file_existssigue respondiendo con las reglas de Win32,trueparaa.txt.).