Standard library / primitives
Files & I/O
All file access is gated by the file capability (file.read / file.write for least-privilege). Writes are atomic (temp file + rename).
A scope is a glob by segment (engine v0.6.45+): file.read("./data/") is the files directly in ./data; file.read("./data/*") is ./data itself and everything below it, at any depth. The full rules — .., links, the whole disk — are in Capabilities.
-- 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"])Reading§
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 says why it cannot read a file (engine v0.6.45+). File not found: <path> is only for a path that does not exist; anything else names its cause:
| The error says | What it means |
|---|---|
… it is a directory (use list_dir) | the path is a folder |
… it is not valid UTF-8 text (use read_file_bytes) | a binary file, or text in another encoding |
… in use by another process … retry later | Windows: another process holds the file open without sharing it |
| a broken link, a permission | that, by name |
read_file_bytes and edit_file give the same answers.
Writing & editing§
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 needs file.read as well as file.write on the path (engine v0.6.45+): it reads the file to find the text, and its answers (not found, ambiguous, how many replaced) tell what is inside. require file("./notes/**") gives both.
Navigating & searching§
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
grepon a folder needsfile.read("<folder>/") (engine v0.6.45+): it reads the whole tree, andfile.read("./proj")is the folder alone. Without it:Capability not granted: file_read("proj/"). It does not follow links to folders (it skips them).is_link(engine v0.6.45+) marks a symlink or a junction. A link'sis_diris that of what it points to, the same inlist_dirandfile_info; inlist_dira link'ssizeis0(askfile_info, which answers only when the link leads inside your grant — one that leads out is refused, see Capabilities).
Deleting (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
Deleting is a write: delete_file needs file.write on that path; delete_dir needs it on the directory and, recursively, on every path inside it — the same grant that would let you delete them one by one. With file.write("./tmp") alone, a recursive delete of ./tmp is refused at the first child (file_write("tmp/k")) and nothing is removed; file.write("./tmp/**") covers the folder and everything below it. Every path is checked before anything is deleted. A path inside a synsema build bundle is read-only, so deleting it is refused.
Where am I — cwd(), ~ (engine v0.6.20+)§
cwd() returns the real working directory, normalized to forward slashes (C:/Users/me/proj), and needs file.read(".") — the same grant list_dir(".") needs; file.read("./") and file.read("") cover it too. file.read("./") and file.read("") do not (engine v0.6.45+): they name the files of the folder, not the folder. The absolute path is information about the host (user name, disk layout), so under --sandbox it is not free. In a synsema build binary it is the directory the binary was started from, never the bundle.
~ and ~/… expand to the home directory (HOME, else USERPROFILE) in paths and in scopes: require file.read("~/.config/app/*"), then read_file("~/.config/app/x.toml"). ~user/ is not supported (clear error).
The program's assets vs. the user's files — bundle: / disk:§
In a binary made with synsema build, a path that is in the bundle is part of the program: read_file, read_file_bytes, file_exists, file_info and grep read it from the bundle, with no file capability and an audit line that says so (bundled asset (part of the program)); a file with the same relative name in the working directory never shadows it. A path that is not in the bundle is the user's and goes to the disk with file.read, as always. Two prefixes make it explicit: read_file("bundle:data.txt") forces the bundle (an error if it is not there), read_file("disk:data.txt") forces the disk (needs file.read). list_dir(path) lists the disk only; list_dir("bundle:") and list_dir("bundle:sub/") list the bundle.
Archives — zip and 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
entriesare{"path", "bytes"}or{"path", "from": <file>};fromneedsfile.read(a bundled asset needs none). Paths inside the archive are normalized to/;..and absolute paths are refused at creation. Deterministic: the same entries give byte-identical archives (fixed timestamps).- Extracting needs
file.writeon every path it writes — the same grantwrite_filewould need for those paths — sofile.write("./out/*")extracts into./out(created if missing) with its subfolders;file.write("./out/")covers only the entries at the top (dir/hello.txtis refused:Capability not granted: file_write("out/dir/hello.txt")), and an exact scope on./outalone is refused at the first entry. Zip-slip is rejected: an entry that would land outside the destination (../evil.txt) is an error and nothing is written. Symlinks and hard links inside the archive are skipped. Since engine v0.6.46 each entry is written likewrite_file: atomically (no half file if it fails), and when a link on disk already has the entry's name, the link is replaced by the file instead of written through (v0.6.45 failed there withAcceso denegado/access denied on a junction). - Bombs:
opts.max_entries(default 10 000) andopts.max_bytes(default 512 MB) count the real decompressed bytes, never the size the header declares; on overflow, error and nothing written. The price of "nothing written" is that the content is held in memory (up tomax_bytes) before the first file lands — lower the ceiling or extract in parts for very large archives. - Native only: under
--profile pure(and in the wasm builds) the four are honest stubs (not available in the pure profile)..gitignoreis not interpreted — you buildentries.
gzip — gzip / gunzip (engine v0.6.45+)§
For loose .json.gz / .csv.gz files (a .tar.gz is 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)takes text or bytes and gives bytes. It is deterministic: no file name or time in the header, so the same input gives the same bytes.gunzip(bytes, opts?)gives bytes (decode(raw, "utf8")for text). It reads concatenated members (cat a.gz b.gz > ab.gz), asgzip -ddoes.- A bomb is stopped: by default it refuses to produce more than the larger of 256 MiB and 64 × the input, with
gunzip: the uncompressed data is larger than max_bytes (…); raise it with {"max_bytes": n} if you expect that much. - Pure: also under
--profile pureand in the wasm build. The file itself isread_file_bytes/write_file, with their own capability.
File formats — CSV, Parquet, JSON Lines§
The format builtins are pure: text or bytes in, values out. The disk is read_file / read_file_bytes / write_file, with their own file capability. They all produce or take a table — a list of maps, the same shape sql() returns — which is what Data analysis works on.
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?) → a list of maps (first row = headers). Full RFC 4180: quoted fields, embedded delimiters, quotes and newlines, "" escapes, CRLF/LF, a UTF-8 BOM.
- An empty field is
nothing— missing data — typed or not. A quoted empty field""is the empty text. A blank line is always skipped. - Without types every field stays text, losslessly (
"007"stays"007"). - A field that does not fit its type is an error with the line and the column (
csv_parse: line 2, column "a": "x" is not an int). So are an unclosed quote, uneven fields, duplicate headers and an unknown option — all catchable withtry/recover.
| Option | Meaning |
|---|---|
"types" | {"col": type} per column — the recommended form. "int" (exact: "007" → 7), "float" (reads nan/inf; 1e400 is an error, never infinity), "decimal" (exact, any number of digits, an exponent allowed: 1.5e-3 → 0.0015), "text", "bool" (true/false/1/0/yes/no, any case), "date" (YYYY-MM-DD), "datetime" (ISO 8601; no zone → UTC). Unnamed columns stay text; naming a column the header does not have is an error |
"missing" | a list of texts that mean missing, like ["NA", "NULL", "-"] (polars null_values, pandas na_values): those texts unquoted are nothing; a quoted "NA" stays text |
"headers" | false → a list of lists (every row is data) |
"delimiter" | one character, e.g. ";" or "\t" (not the quote or a line end) |
"numbers" | true → the old guess: every numeric-looking field becomes a number (ids and zip codes lose their zeros). Prefer types |
csv_encode(rows, opts?) → text. Rows are a list of maps (headers = the first map's keys, in order) or a list of lists. Integers without decimals; nothing → an empty field and the empty text → "", so csv_parse(csv_encode(rows)) == rows; dates, datetimes and durations → their ISO text; bytes → base64; a secret → an error (engine v0.6.46+: csv_encode: row 1, column "k": secret(K) is never written as text — leave it out of the rows; before, it came out as [redacted]); a nested list or map → an error suggesting json_encode for that field.
| Option | Meaning |
|---|---|
"headers" | a list of column names → the order and subset of columns |
"delimiter" | as in csv_parse |
"eol" | "\r\n" (default, Excel-friendly) or "\n" |
"missing" | a mark written for each nothing, unquoted, such as "NA" (pandas na_rep); a text equal to the mark is quoted. Read it back with {"missing": ["NA"]} for an exact round trip. An empty mark is an error |
"escape_formulas" | true → a text cell that starts with =, +, -, @, tab or CR gets a leading ', so Excel and Sheets show it instead of running it (OWASP "CSV injection"). Numbers are untouched. Off by default, because it changes the data — turn it on for any CSV a person opens in a spreadsheet with text that came from users |
One corner: in a one-column CSV a row whose only field is nothing would be a blank line, which reading skips, so it is written "" (as Python's writer does) and comes back as the empty text — or nothing if you read that column with a type; csv_encode warns once on stderr. The "missing" option makes that round trip exact too.
Parquet — parquet_read / parquet_write§
Columnar files as written by polars, pandas, Spark and DuckDB. Bytes in, bytes out. Native builds only (not in the wasm build).
parquet_read(bytes, opts?) → typed rows: Int64 → exact integer, Double/Float → float, Decimal → decimal, String → text, Binary → bytes, Bool → bool, Date → date, Timestamp → datetime (UTC, or the column's zone — the one Synsema stored, or the one polars/pyarrow wrote, fixed offsets like +05:30 included; milli-, micro- and nanoseconds), Duration → duration, Time (time of day) → a duration since midnight, lists and groups → nested lists and maps, null → nothing. Compression zstd, snappy, gzip and lz4. A file that is not Parquet, or that has two columns with the same name, is an error.
- Expansion caps: a small file cannot make the engine allocate gigabytes. It refuses, before allocating, a page over 256 MiB uncompressed, a file that expands past
max_bytes(default: the larger of 1 GiB and 64 × the file) or with more thanmax_cells= rows × columns (default 50 000 000). Raise them on purpose for a big file you trust:parquet_read(b, {"max_cells": 200000000, "max_bytes": 8589934592}). Any other option is an error. - Read a part (engine v0.6.45+):
parquet_read(b, {"columns": ["id", "amount"], "row_groups": [0, 2], "limit": 1000})reads only those columns, those row groups (counted from 0) and at most that many rows; the caps above count only what is read, and the input is copied once. A column the file does not have is an error that lists the ones it has (parquet_read: the file has no column "zz" (columns: a, b)). - The shape without the data:
parquet_info(bytes)→{rows, columns: [{name, type}], row_groups: [{rows, bytes}]}— decide what to read before reading it.
#### parquet_scan — a part of a file, straight from the disk (engine v0.6.46+)
parquet_read takes the file's bytes, so the whole file is read into memory first. parquet_scan(path, opts?) reads the file on disk by parts: the footer with the metadata, then only the pages of the columns and row groups it is asked for. A file of gigabytes with one column asked reads that column.
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")
- Options: the same as
parquet_read, with the same checks.columns— a list of names; the rows keep the file's column order, whatever order you list them in;[]gives one empty map per row.row_groups— indexes from 0;[]gives[].limit— a positive integer (0,-1or1.5is an error).max_cells(default 50 000 000 = rows × columns read) andmax_bytes(default the larger of 1 GiB and 64 × the file size), counted only over what is read and checked before allocating. A page that declares more than 256 MiB is refused even withlimit: no option raises that one. Nooptsreads the whole file. - Result: the same typed rows
parquet_readgives —parquet_scan(p)equalsparquet_read(read_file_bytes(p)). - Capability:
require file.read(<the path>)(a folder:file.read("./data/**")); checked and opened in one step, likeread_file.synsema checknames the missingrequire. A file bundled into asynsema buildbinary with--includeis read from the bundle, without the grant. - Lineage: each call is an input
{source: "parquet_scan", what: <the path as written>, sha256, bytes, encoding: "jcs"}, wheresha256is the hex ofsha256(canonical_json(rows))of the rows it returned andbytestheir length.parquet_read(b)records onlybytes <n>;parquet_scanrecords the path. - With labels, like
read_file: a call under a branch decided by a private value is alabel_violation. - What it does not do: there is no streaming iterator — the rows come back as one list, so bound a big read with
columns,row_groupsandlimit.parquet_infostill takes bytes (a path is an error that says so). Not in the wasm build.
| Error | Fix |
|---|---|
parquet_scan: expected a path, got bytes — for bytes already in memory, parquet_read(bytes) | pass the path, or use parquet_read |
parquet_scan: the file has no column "zz" (columns: id, name, v) | a name from that list |
parquet_scan: row group 1 does not exist (the file has 1: 0..1) | an index below the count in parquet_info(…).row_groups |
parquet_scan: unknown option "bogus" (valid: columns, row_groups, limit, max_cells, max_bytes) | one of those |
parquet_scan: option "limit" must be a positive integer, got 0 | a whole number from 1 |
parquet_scan: opts must be a map, got text | a map |
parquet_scan: the file has 30 cells (rows × columns); the limit is 5 — raise it on purpose with {"max_cells": n} | read less, or raise it for a file you trust |
parquet_scan: the file expands to more than 10 bytes uncompressed (max_bytes) — raise it on purpose with {"max_bytes": n} | the same |
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 | the file is broken or hostile |
parquet_scan: not a Parquet file: … | it is not 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 | rewrite the file with distinct names |
File not found: missing.parquet | the path |
`Capability not granted: file_read("other.parquet") — … add require file.read("other.parquet") …` | that require |
parquet_write(rows, opts?) → bytes. One schema per column, inferred, every column nullable: int → INT64, float → DOUBLE (a column mixing ints and floats → DOUBLE), decimal → DECIMAL(38, the column's largest scale), text → STRING, bool → BOOLEAN, bytes → BINARY, date → DATE, datetime → TIMESTAMP in µs (ns when a value has nanoseconds) with its zone, duration → INT64 in µs (or ns) with its unit. A key missing from a row is nothing there. opts.compression = "snappy" (default), "zstd", "gzip", "lz4" or "none".
- Other tools read the types (engine v0.6.30+): the file carries the Arrow schema (
ARROW:schema), as pyarrow and polars write it, so pandas, pyarrow and polars read adurationcolumn as a duration (timedelta64) and adatetimecolumn with its IANA zone (datetime64[us, Europe/Madrid]). A fixed-offset zone (+05:30) goes as UTC in that schema, because polars cannot open a file with one; Synsema still reads the offset back. - Round trip:
parquet_read(parquet_write(rows)) == rows, zones, nanoseconds and durations included. - Errors instead of silent loss: a column that mixes types (
column "a" mixes Int and Text); a nested list or map (json_encodethat column first); an integer beyond 2^53 in a column that mixes ints and floats (a DOUBLE would round it — make the column decimal or text); an integer beyond 64 bits; and a column where one value has nanoseconds and another lies beyond what nanoseconds reach (a datetime outside 1677–2262, a duration beyond ±292 years) — one unit per column cannot hold both, so put the far values in another column or store it as text (text(x)keeps every digit).
JSON Lines — jsonl_encode / jsonl_decode§
One JSON value per line: jsonl_decode(read_file("events.jsonl")), write_file("out.jsonl", jsonl_encode(rows)). Blank lines are skipped and a bad line is an error naming its number. The full contract (big integers, NaN, fallback form) is in JSON, XML & TOML.
Path scopes are faithful — a .. escape outside the declared scope is denied, and a link (symlink or junction) below the grant that leads out of it is refused (engine v0.6.45+). See Capabilities.
No window between the check and the open (engine v0.6.46+, Linux and Windows). v0.6.45 checked a path on disk and then opened it by name, so a folder swapped for a link in between was followed outside the grant. Now the file is opened without following any link (openat2 with RESOLVE_NO_SYMLINKS on Linux, NtCreateFile with OBJ_DONT_REPARSE on Windows): if it opens, it is the file the check judged. With a link on the way, it is opened following it but without access to its data, the same check runs again on where it really is, and only then the same file is reopened with the access asked for. A link that stays inside the grant still works. To replace a file (write_file, edit_file, zip_extract/tar_extract) the folder is opened the same way, and the temporary file, the rename and the cleanup all go relative to it, so the name is never resolved again.
- Covered:
read_file(whole or a range of lines),read_file_bytes,edit_file,write_file,append_file,grep, the files ofzip_create/tar_createand the entries ofzip_extract/tar_extract,mtls_identity, the fonts ofsvg_to_png/svg_to_pdf,render(and itsinclude/layout) and the files of astaticroute. - Not covered (as in v0.6.45):
list_dir,file_exists,file_info,delete_file,delete_dir, the folderswrite_filecreates when they are missing, and the PEM files oftls cert … key …inserve; macOS, a Linux kernel withoutopenat2(before 5.6) or a container whose seccomp refuses it (Docker before 20.10), and UNC paths on Windows; and a hard link, which is not visible from its name. - Windows names are opened as written.
a.txt.anda.txt(a trailing dot or space) no longer opena.txt, and…/CONdoes not open the console device: they are errors (file_existsstill answers by the Win32 rules,truefora.txt.).