Skip to content

Standard Library: I/O

Shape provides a unified I/O module for files, network sockets, processes, and paths.

use std::core::io

All I/O functions live under the io namespace. Handles returned by io::open, io::tcp_connect, and similar functions implement the Drop trait, so they are automatically cleaned up when they go out of scope.

Open a file with io::open and read its contents:

let f = io::open("data.csv")
let content = io::read_to_string(f)
io::close(f)

The second argument controls the mode. Omitting it or passing "r" opens the file for reading. To read a fixed number of bytes instead of the entire file, use io::read:

let f = io::open("binary.dat", "r")
let header = io::read(f, 64) // first 64 bytes (string)
io::close(f)

For raw byte arrays, use io::read_bytes:

let f = io::open("image.png", "r")
let bytes = io::read_bytes(f, 1024) // Array<int>

In practice, you do not need to close handles manually. The handle implements Drop and is closed automatically when it goes out of scope:

fn read_data() -> string {
let f = io::open("data.csv")
io::read_to_string(f)
// f is closed automatically when the function returns, even on error
}

Open a file for writing with "w" (truncate) or "a" (append):

let f = io::open("output.csv", "w")
io::write(f, "name,value\n")
io::write(f, "alpha,142.58\n")
io::write(f, "beta,378.20\n")
// f is closed automatically when it goes out of scope

Writes are buffered. Call io::flush to force buffered data to disk before the block exits:

let f = io::open("log.txt", "a")
io::write(f, "checkpoint reached\n")
io::flush(f)
// ... more work ...
io::write(f, "done\n")
// f is flushed and closed when it goes out of scope

Query file metadata without opening a handle:

FunctionReturnsDescription
io::exists(path)boolFile or directory exists
io::stat(path)objectFile metadata (size, modified, created, is_file, is_dir)
io::is_file(path)boolPath is a regular file
io::is_dir(path)boolPath is a directory

The object returned by io::stat has the following fields:

let info = io::stat("data.csv")
print(info.size) // file size in bytes
print(info.modified) // last modification timestamp
print(info.created) // creation timestamp
print(info.is_file) // true
print(info.is_dir) // false
FunctionDescription
io::mkdir(path)Create a single directory
io::mkdir(path, true)Create directory and all parents recursively
io::remove(path)Remove a file or empty directory
io::rename(old, new)Rename or move a file
io::read_dir(path)List directory contents as an array of strings

Example — list directory contents and filter:

let files = io::read_dir("reports")
let csvs = files.filter(|name| name.ends_with(".csv"))
for path in csvs {
print(f"Found: {path}")
}

Create a nested output directory, then write into it:

io::mkdir("output/reports/weekly", true)
let f = io::open("output/reports/weekly/summary.csv", "w")
io::write(f, header + "\n")
for row in rows {
io::write(f, row.to_csv() + "\n")
}
// f is closed automatically when it goes out of scope

Connect to a TCP server with io::tcp_connect. The returned handle supports reading and writing and is automatically closed when it goes out of scope.

let conn = io::tcp_connect("api.example.com:8080")
io::tcp_write(conn, "GET / HTTP/1.0\r\nHost: api.example.com\r\n\r\n")
let response = io::tcp_read(conn)
print(response)
// conn is closed automatically when it goes out of scope

For server-side TCP, bind a listener and accept connections:

let listener = io::tcp_listen("0.0.0.0:9000")
// Accept a single connection
let client = io::tcp_accept(listener)
let request = io::tcp_read(client)
io::tcp_write(client, "ACK: " + request)
io::tcp_close(client)
io::tcp_close(listener)

TCP function reference:

FunctionDescription
io::tcp_connect(addr)Connect to a remote address, returns stream handle
io::tcp_listen(addr)Bind and listen on an address, returns listener handle
io::tcp_accept(listener)Accept a connection, returns stream handle
io::tcp_read(handle)Read available data from stream
io::tcp_write(handle, data)Write data to stream
io::tcp_close(handle)Close connection or listener

UDP sockets are connectionless. Bind a socket, then send and receive datagrams:

let sock = io::udp_bind("0.0.0.0:5000")
io::udp_send(sock, "hello", "192.168.1.10:5000")
let msg = io::udp_recv(sock)
print(f"Received '{msg.data}' from {msg.from}")
// sock is closed automatically when it goes out of scope

io::udp_recv returns an object with two fields:

FieldTypeDescription
datastringThe received payload
fromstringThe sender’s address ("host:port")

UDP function reference:

FunctionDescription
io::udp_bind(addr)Bind a UDP socket to a local address
io::udp_send(handle, data, addr)Send a datagram to a remote address
io::udp_recv(handle)Receive a datagram, returns { data, from }

Use io::exec to run a command to completion and capture its output:

let result = io::exec("ls", ["-la", "/tmp"])
print(result.status) // 0
print(result.stdout) // directory listing
print(result.stderr) // empty on success

The return value has three fields:

FieldTypeDescription
statusintProcess exit code (0 = success)
stdoutstringCaptured standard output
stderrstringCaptured standard error

Example — running an external tool and checking the result:

let result = io::exec("shape", ["check", "src/main.shape"])
if result.status != 0 {
print(f"Check failed:\n{result.stderr}")
}

For long-running or interactive processes, use io::spawn to start a subprocess without waiting for it to finish:

let proc = io::spawn("tail", ["-f", "/var/log/app.log"])
// Read lines from stdout as they arrive
let line = io::read_line(proc)
print(line)
// Kill the subprocess when done
io::process_kill(proc)
// proc is cleaned up automatically when it goes out of scope

A spawned process handle gives access to the child’s standard streams. The handle implements Drop, so the process is killed if it is still running when the handle goes out of scope.

Process management functions:

FunctionDescription
io::spawn(cmd, args)Start a subprocess, returns process handle
io::process_wait(handle)Block until the process exits, returns exit code
io::process_kill(handle)Send termination signal to the process
io::process_write(handle, data)Write to the subprocess’s stdin
io::process_read(handle, n?)Read up to n bytes from stdout
io::process_read_err(handle, n?)Read up to n bytes from stderr
io::process_read_line(handle)Read one line from stdout

Access the current process’s own standard streams:

let stdin = io::stdin()
let stdout = io::stdout()
let stderr = io::stderr()

Read a line of user input:

io::write(io::stdout(), "Enter your name: ")
io::flush(io::stdout())
let name = io::read_line(io::stdin())
print(f"Hello, {name}")

Standard stream functions:

FunctionDescription
io::stdin()Handle to standard input
io::stdout()Handle to standard output
io::stderr()Handle to standard error
io::read_line(handle)Read a single line from a stream

For non-blocking file access in async contexts, io exposes async-suffixed variants of the common operations:

FunctionDescription
io::read_file_async(path)Read entire file as a string
io::write_file_async(path, data)Write string to file
io::append_file_async(path, data)Append string to file
io::read_bytes_async(path)Read entire file as Array<int>
io::exists_async(path)Check if a path exists
async fn load() -> string {
await io::read_file_async("data.csv")
}

Convenience wrappers that combine file I/O with gzip compression in one call:

FunctionDescription
io::read_gzip(path)Read a gzip-compressed file, returns decompressed string
io::write_gzip(path, data, level)Compress data and write to path (level 0-9)
io::write_gzip("backup.txt.gz", "hello world", 6)
let restored = io::read_gzip("backup.txt.gz")

These are synchronous helper functions that manipulate path strings without touching the filesystem:

FunctionDescriptionExample
io::join(a, b, ...)Join path segmentsio::join("/home", "user", "file.txt") returns "/home/user/file.txt"
io::dirname(path)Parent directoryio::dirname("/home/user/file.txt") returns "/home/user"
io::basename(path)File name componentio::basename("/home/user/file.txt") returns "file.txt"
io::extension(path)File extension without dotio::extension("report.csv") returns "csv"
io::resolve(path)Resolve to absolute pathExpands ., .., and symlinks

Example — build an output path from an input path:

let input = "data/raw/records.csv"
let output = io::join(
io::dirname(input),
io::basename(input).replace(".csv", "_clean.csv")
)
// output == "data/raw/records_clean.csv"

All I/O handles implement the Drop trait, which means they are cleaned up automatically when they go out of scope — even when an error occurs. Files are closed, TCP connections are shut down, and spawned processes are killed.

// File: auto-closed when f goes out of scope
let f = io::open("output.csv", "w")
io::write(f, "header1,header2\n")
for row in data {
io::write(f, row.to_csv() + "\n")
}
// TCP: connection closed when conn goes out of scope
let conn = io::tcp_connect("api.example.com:443")
io::tcp_write(conn, request)
let response = io::tcp_read(conn)
// Process: killed if still running when proc goes out of scope
let proc = io::spawn("long-running-task", [])
let output = io::read_line(proc)

Use blocks to limit a handle’s lifetime when multiple resources are involved:

fn transform_file(input_path: string, output_path: string) {
let cleaned = {
let input = io::open(input_path)
let content = io::read_to_string(input)
process(content)
// input is closed here
}
let output = io::open(output_path, "w")
io::write(output, cleaned)
// output is closed when the function returns
}

Multiple handles in the same scope are dropped in reverse declaration order, ensuring dependents are cleaned up before their dependencies.

use std::core::io
let f = io::open("records.csv")
let lines = io::read_to_string(f).split("\n")
let header = lines[0].split(",")
let rows = lines.slice(1).filter(|l| l.length > 0).map(|line| {
let fields = line.split(",")
{
name: fields[0],
value: fields[1].to_number(),
count: fields[2].to_int()
}
})
let total = rows.map(|r| r.value * r.count).sum()
print(f"Total: {total}")
// f is closed automatically when it goes out of scope
use std::core::io
fn send_request(host: string, port: int, message: string) -> string {
let addr = f"{host}:{port}"
let conn = io::tcp_connect(addr)
io::tcp_write(conn, message + "\n")
let response = io::tcp_read(conn)
response
// conn is closed automatically when the function returns
}
let reply = send_request("localhost", 8080, "PING")
print(f"Server replied: {reply}")
use std::core::io
let result = io::exec("git", ["log", "--oneline", "-5"])
if result.status == 0 {
let commits = result.stdout.split("\n").filter(|l| l.length > 0)
print(f"Last {commits.length} commits:")
for commit in commits {
print(f" {commit}")
}
} else {
print(f"git error: {result.stderr}")
}

Poll for new files using io::read_dir and a sleep loop:

use std::core::io
use std::core::time
let mut seen = []
fn check_new_files() {
let entries = io::read_dir("inbox")
let csvs = entries.filter(|name| name.ends_with(".csv"))
for name in csvs {
let path = io::join("inbox", name)
if !seen.contains(path) {
seen.push(path)
print(f"New file: {path}")
process_file(path)
}
}
}
// Poll every 5 seconds
loop {
check_new_files()
time::sleep_sync(5000)
}
  • Resource Management — automatic scope-based drop and the Drop trait
  • Async — combining I/O with async/await and structured concurrency