I/O And Filesystem
Import io for console output, file and directory operations, file handles,
temporary files, buffers, CSV convenience helpers, and stdin.
io owns filesystem mutation in Geblang. sys owns process and environment
state, while path owns path string manipulation. For example, use path.join
to build a path, io.mkdir to create it, and sys.tmpdir when you need the
host temporary directory.
import io;
import path;
import sys;
let work = path.join(sys.tmpdir(), "geb-work");
io.mkdir(work, 0o755);
Most io functions raise an error on OS failure: missing files, permission
errors, invalid handles, failed flushes, and unsupported operations are not
silently ignored. Use try/catch when absence or failure is expected.
Console Output
| Function | Returns | Description |
|---|---|---|
print(value) |
void |
Write a printable value without a newline |
println(value) |
void |
Write a printable value followed by a newline |
stdoutWrite(value) |
void |
Write a value (converted to a string) directly to stdout, no trailing newline |
stderrWrite(value) |
void |
Write a value (converted to a string) directly to stderr, no trailing newline |
stderrPrintln(value) |
void |
Write a printable value to stderr with a newline |
io.print("hello ");
io.println("world");
io.stdoutWrite("raw\n");
io.stderrWrite("warning\n");
io.stderrPrintln("fatal");
print and println accept any printable Geblang value. stdoutWrite and
stderrWrite write a value (converted to a string) with no trailing newline
and no other formatting.
Whole-File Operations
Use whole-file helpers for small files that fit comfortably in memory.
| Function | Returns | Description |
|---|---|---|
readText(path) |
string |
Read a full file as text |
writeText(path, text) |
void |
Create or truncate a file and write text |
appendText(path, text) |
void |
Append text, creating the file if needed |
readBytes(path) |
bytes |
Read a full file as bytes |
writeBytes(path, data) |
void |
Create or truncate a file and write bytes |
appendBytes(path, data) |
void |
Append bytes, creating the file if needed |
io.writeText("notes.txt", "first line\n");
io.appendText("notes.txt", "second line\n");
let text = io.readText("notes.txt");
let data = io.readBytes("notes.txt");
For large files or files you want to stream incrementally, use file handles.
File Existence And Metadata
| Function | Returns | Description |
|---|---|---|
exists(path) |
bool |
true if the path exists |
stat(path) |
dict |
File metadata, following symlinks |
lstat(path) |
dict |
File metadata, not following symlinks |
stat(path) and lstat(path) return:
| Key | Type | Description |
|---|---|---|
name |
string |
Base name |
size |
int |
Size in bytes |
isDir |
bool |
Whether the path is a directory |
isFile |
bool |
Whether the path is a regular file |
isSymlink |
bool |
Whether the path is a symbolic link |
mode |
int |
Permission bits as a plain decimal int (e.g. 420, whose octal form 0o644 is the familiar permission notation) |
modUnix |
int |
Last modification time as a Unix timestamp |
if (io.exists("app.log")) {
let info = io.stat("app.log");
io.println(info["size"] as string);
io.println(info["mode"] as string);
}
exists returns false for missing paths. stat follows symlinks, so
isSymlink is always false; lstat reports on the link itself. Both raise an
error if the path cannot be inspected.
Directories
| Function | Returns | Description |
|---|---|---|
mkdir(path, mode) |
void |
Create a directory and missing parents |
remove(path) |
void |
Remove a file or directory tree |
rename(oldPath, newPath) |
void |
Rename within one filesystem (fails across devices) |
listDir(path) |
list<string> |
Return child names in one directory |
scanDir(path) |
list<dict> |
Return child entries with type flags |
walkDir(path) |
list<dict> |
Recursively return metadata for a directory tree |
let root = io.tempDir("geb-docs-*");
defer io.remove(root);
io.mkdir(root + "/nested/reports", 0o755);
io.writeText(root + "/nested/reports/today.txt", "ok");
for (name in io.listDir(root + "/nested")) {
io.println(name);
}
mkdir creates missing parents. The mode argument is the requested permission
bits, such as 0o755. The host operating system may still apply its normal
umask.
remove removes files and directories recursively. Use it carefully when a path
comes from user input.
walkDir(path) returns one dictionary per file or directory:
| Key | Type | Description |
|---|---|---|
path |
string |
Full walked path |
name |
string |
Base name |
isDir |
bool |
Whether the entry is a directory |
size |
int |
Size in bytes |
modUnix |
int |
Last modification time as a Unix timestamp |
for (entry in io.walkDir("src")) {
if (!(entry["isDir"] as bool) && entry["name"].endsWith(".gb")) {
io.println(entry["path"]);
}
}
scanDir(path) lists one directory (not recursive) and returns one dictionary
per child with name, isDir, isFile, and isSymlink keys. It is cheaper
than calling stat per entry because the type is read during the directory
scan.
for (entry in io.scanDir("src")) {
if (entry["isDir"] as bool) {
io.println(entry["name"]);
}
}
Copying And Moving
| Function | Returns | Description |
|---|---|---|
copy(src, dst) |
void |
Copy a file, overwriting dst and preserving mode bits |
copyTree(src, dst) |
void |
Recursively copy a file or directory tree |
move(src, dst) |
void |
Move a file or tree, across filesystems if needed |
rename is the fast primitive: it renames within a single filesystem and fails
with a cross-device error when src and dst are on different mounts. move
tries rename first and falls back to copy-then-delete across filesystems, so
it is the right choice when the destination may live on another device (for
example moving a file out of the host temporary directory).
let tmp = io.tempFile("settings-*.tmp");
io.writeText(tmp, newText);
io.move(tmp, "settings.conf"); # safe even if the temp dir is a different mount
copy and move overwrite an existing destination. copyTree recreates
symbolic links as links rather than following them.
Atomic Writes And Timestamps
| Function | Returns | Description |
|---|---|---|
touch(path) |
void |
Create the file if absent, else update its modification time |
writeTextAtomic(path, text) |
void |
Write text atomically via a same-directory temp file and rename |
writeTextAtomic writes to a temporary file in the destination's directory,
flushes it, and renames it over the target, so a reader never observes a
partially written file. Because the temporary file shares the destination
directory, the final rename stays on one filesystem and is atomic.
io.writeTextAtomic("config.json", rendered);
io.touch("build/.stamp");
Permissions, Ownership, And Links
| Function | Returns | Description |
|---|---|---|
chmod(path, mode) |
void |
Change permission bits |
chown(path, uid, gid) |
void |
Change numeric owner and group |
symlink(target, linkPath) |
void |
Create a symbolic link |
readLink(linkPath) |
string |
Read a symbolic link target |
io.writeText("secret.txt", "token");
io.chmod("secret.txt", 0o600);
io.symlink("secret.txt", "latest-secret.txt");
io.println(io.readLink("latest-secret.txt"));
chmod and chown are OS-level operations. On some platforms, especially
Windows, permission semantics may differ from Unix-style mode bits. chown
normally requires appropriate OS privileges.
Temporary Files And Directories
| Function | Returns | Description |
|---|---|---|
tempFile(pattern) |
string |
Create a temporary file and return its path |
tempDir(pattern) |
string |
Create a temporary directory and return its path |
Use * in the pattern as the random suffix placeholder.
let file = io.tempFile("geb-example-*.txt");
defer io.remove(file);
io.writeText(file, "temporary data");
io.println(io.readText(file));
let dir = io.tempDir("geb-work-*");
defer io.remove(dir);
io.mkdir(dir + "/cache", 0o755);
Temporary helpers create the path immediately. They do not automatically clean
up; use defer io.remove(path) when the file or directory is short-lived.
File Handles
File handles are integer handles owned by the runtime. Open a handle when you need incremental reads/writes, flushing, file locking, or sync control.
| Function | Returns | Description |
|---|---|---|
open(path, mode) |
int |
Open a file handle |
close(handle) |
void |
Close a file handle |
read(handle, n) |
string |
Read up to n bytes |
readAll(handle) |
string |
Read all remaining data |
readLine(handle) |
string |
Read one line (newline stripped); null at EOF |
readLines(handle) |
list<string> |
Read all remaining lines |
write(handle, text) |
int |
Write text; returns bytes written |
writeln(handle, text) |
int |
Write text and newline |
seek(handle, offset, whence) |
int |
Reposition the handle; returns the new offset |
tell(handle) |
int |
Current byte offset |
truncate(handle, size) |
void |
Resize the open file to size bytes |
atEnd(handle) |
bool |
Whether the handle is at end-of-file |
flush(handle) |
void |
Flush buffered data |
sync(handle) |
void |
Flush file data and metadata to storage |
dataSync(handle) |
void |
Flush file data to storage |
Supported open modes:
| Mode | Meaning |
|---|---|
"r" |
Read-only |
"w" |
Write, create, truncate |
"a" |
Append, create if needed |
"r+" |
Read and write (file must already exist) |
"w+" |
Read and write, create, truncate |
"a+" |
Read and append, create if needed |
"x" |
Exclusive create for writing (fails if the file exists) |
"x+" |
Exclusive create for read and write (fails if the file exists) |
let f = io.open("app.log", "a");
defer io.close(f);
io.writeln(f, "started");
io.flush(f);
Reading incrementally:
let f = io.open("app.log", "r");
defer io.close(f);
let chunk = io.read(f, 4096);
while (chunk != "") {
io.print(chunk);
chunk = io.read(f, 4096);
}
Random access with seek, tell, and truncate (open in a read-write mode):
let f = io.open("record.bin", "r+");
defer io.close(f);
io.seek(f, 16, "start"); # absolute offset from the start
let field = io.read(f, 4);
io.println("${io.tell(f)}"); # 20
io.seek(f, -4, "end"); # 4 bytes before the end
io.seek(f, 0, "start");
io.truncate(f, 16); # shrink the file to 16 bytes
seek's whence is "start", "current", or "end"; it returns the new
absolute offset. tell reports the current offset and atEnd reports whether
the handle has reached end-of-file. After a seek or truncate, line reads
(readLine) re-synchronise to the new position.
Always close file handles. defer io.close(handle) is the normal pattern.
File Object (file Module)
The file module wraps a handle in a File object so the same operations read
as methods, and adds a context manager that closes the handle for you. Open one
with file.open(path, mode) (the modes match io.open).
| Method | Returns | Description |
|---|---|---|
read(n) / readBytes(n) |
string / bytes |
Read up to n bytes |
readAll() |
string |
Read all remaining content |
readLine() |
?string |
Next line (newline stripped); null at EOF |
readLines() / lines() |
list<string> / generator<string> |
All / lazy lines |
write(text) / writeln(text) / writeBytes(data) |
int |
Write; returns bytes written |
seek(offset, whence) / tell() |
int |
Reposition / report offset |
truncate(size) / atEnd() |
void / bool |
Resize / end-of-file check |
flush() / sync() |
void |
Flush buffered / persist to storage |
lock() / tryLock() / unlock() |
void / bool / void |
Advisory locking |
close() / isClosed() |
void / bool |
Close / closed check |
A File implements the context-manager protocol, so a with block closes it on
exit, and iterates by line:
import file;
with (f = file.open("app.log", "r")) {
for (line in f) {
if (line.contains("ERROR")) {
io.println(line);
}
}
}
Use the raw io.* handle functions when you already hold a low-level handle;
use File when method chaining or automatic close is more natural. The
streams module's IOStream offers the same surface for memory and standard
streams as well as files.
Streams And Capture
Use stream constructors when you want file-like resources that are not opened
from filesystem paths. io.open(path, mode) remains filesystem-only; stdout,
stderr, stdin, and memory streams are created explicitly.
| Function | Returns | Description |
|---|---|---|
memory() |
IOStream |
Create an empty in-memory read/write stream |
memory(initial) |
IOStream |
Create a memory stream from a string or bytes value |
stdout() |
IOStream |
Open the current evaluator stdout as a write stream |
stderr() |
IOStream |
Open the current evaluator stderr as a write stream |
stdin() |
IOStream |
Open the current evaluator stdin as a read stream |
toString(stream) |
string |
Return memory-backed stream contents |
captureStdout() |
IOCapture |
Redirect stdout to a memory capture |
captureStderr() |
IOCapture |
Redirect stderr to a memory capture |
redirectStdout(stream) |
callable<void> |
Redirect stdout to a writable stream until the returned restore function is called |
redirectStderr(stream) |
callable<void> |
Redirect stderr to a writable stream until restored |
redirectStdin(stream) |
callable<void> |
Redirect stdin to a readable stream until restored |
Streams work with the same read, readBytes, readAll, write, writeBytes,
writeln, flush, and close functions used by file handles, where the
operation is supported by the stream.
let mem = io.memory("hello");
io.writeln(mem, " streams");
io.println(io.toString(mem)); # hello streams
Capture helpers are evaluator-local. They are designed for tests and framework code that needs to inspect output without writing to the real console:
let capture = io.captureStdout();
io.println("hidden from real stdout");
io.stdoutWrite("also captured\n");
let text = io.toString(capture);
io.close(capture);
io.println(text.contains("hidden"));
Use defer with redirect helpers when a test temporarily replaces a stream:
let mem = io.memory();
let restore = io.redirectStdout(mem);
io.println("captured in memory");
restore();
io.println(io.toString(mem));
Calling io.close(capture) restores the previous stdout or stderr target. Use
io.toString(stream) to inspect memory-backed streams and captures. Use
io.read(stream, n), io.readAll(stream), and io.readBytes(stream, n) when
you want to consume readable stream content.
Stream Protocol (streams Module)
The streams module ships object-shaped wrappers on top of the raw handle
helpers above. They expose the same operations as method calls and let user
classes participate in stream pipelines through the __read / __write /
__close dunder protocol introduced in 1.1.0. The free-function API in
io is unchanged; the wrappers are additive.
| Factory | Returns | Description |
|---|---|---|
streams.open(path, mode = "r") |
IOStream |
Opens a file and wraps it |
streams.memory(initial = "") |
IOStream |
In-memory read/write buffer |
streams.stdin() |
IOStream |
Wraps the current stdin |
streams.stdout() |
IOStream |
Wraps the current stdout |
streams.stderr() |
IOStream |
Wraps the current stderr |
IOStream exposes a uniform method surface regardless of the
underlying handle kind:
| Method | Returns | Description |
|---|---|---|
read(n) |
string |
Reads up to n bytes; empty string at EOF |
readAll() |
string |
Reads the entire remaining contents |
readLine() |
?string |
Next line with the trailing newline stripped; null at EOF |
lines() |
generator<string> |
Lazy generator yielding each line |
write(buf) |
int |
Writes buf; returns bytes written |
writeln(buf) |
int |
Writes buf followed by \n |
flush() |
void |
Flushes any buffered output |
close() |
void |
Closes the underlying resource (idempotent) |
isClosed() |
bool |
true after close() has been called |
toString() |
string |
Memory-backed only: full buffer contents without moving the read cursor. Raises on file / stdin / stdout / stderr handles. |
Iterating a stream walks its lines:
import streams;
import io;
let mem = streams.memory("alpha\nbeta\ngamma\n");
for (line in mem) {
io.println(line);
}
The same shape works for files:
import streams;
let log = streams.open("/var/log/app.log", "r");
defer log.close();
for (line in log) {
if (line.contains("ERROR")) {
io.println(line);
}
}
The dunder protocol lets a user class participate without subclassing:
class HexSource {
int step;
func HexSource() { this.step = 0; }
func __read(int n): string {
if (this.step == 0) { this.step = 1; return "hello "; }
if (this.step == 1) { this.step = 2; return "world"; }
return "";
}
}
let out = streams.memory();
streams.copy(HexSource(), out);
io.println(out.toString()); # hello world
| Helper | Returns | Description |
|---|---|---|
streams.readAll(src, chunk = 4096) |
string |
Reads via src.__read(n) until EOF |
streams.copy(src, dst, chunk = 4096) |
int |
Pipes src.__read into dst.__write; returns total bytes |
The wrappers are intentionally thin. Use the raw io.* functions when you
already have a low-level handle and want the free-function form, and use
the stream classes when method-style chaining, dunder participation, or
for (line in stream) iteration is more natural.
File Locking
| Function | Returns | Description |
|---|---|---|
lock(handle) |
void |
Acquire an exclusive advisory lock, blocking if needed |
tryLock(handle) |
bool |
Attempt an exclusive advisory lock without blocking |
unlock(handle) |
void |
Release a lock |
let f = io.open("state.db", "r+");
defer io.close(f);
io.lock(f);
defer io.unlock(f);
# critical section
Locks are advisory and process-level. Other processes must also cooperate with advisory locking for it to protect shared files.
Buffers
Buffers are in-memory write targets. They are useful when an API accepts a file or buffer handle, or when you want to build text incrementally.
| Function | Returns | Description |
|---|---|---|
buffer() |
IOBuffer |
Create an in-memory buffer |
bufferToString(buffer) |
string |
Return buffer contents as text |
bufferReset(buffer) |
void |
Clear buffer contents |
write and writeln accept buffers as well as file handles:
let buf = io.buffer();
io.write(buf, "hello ");
io.writeln(buf, "world");
io.println(io.bufferToString(buf));
io.bufferReset(buf);
Stdin Helpers
| Function | Returns | Description |
|---|---|---|
stdinReadLine() |
`string | null` |
stdinReadAll() |
string |
Read all of stdin |
withStdin(input, body) |
any |
Runs body with console input served from input instead of stdin |
io.print("Name: ");
let name = io.stdinReadLine();
if (name != null) {
io.println("Hello " + name);
}
io.readLine(handle) reads a line from an open file handle or stream (see File
Handles), not from stdin. For prompted, masked, or richer terminal input, use
the cli module.
Testing interactive prompts
io.withStdin(input, body) runs body with the cli readers (and the
cli.multiChoose key reader) consuming input instead of the real
terminal, then restores stdin. It returns whatever body returns, so a
test can drive a prompt deterministically and assert the result:
let lang = io.withStdin("2\n", func(): any {
return cli.choose("Language:", ["gb", "py", "js"]);
});
/* lang == "py" */
let picked = io.withStdin("j \r", func(): any { /* j = down, space, enter */
return cli.multiChoose("Pick:", ["a", "b", "c"]);
});
/* picked == ["b"] */
The injected string is fed as raw bytes, so key sequences work too:
"\u001b[B" is the down arrow (or "j"), " " toggles, "\r" confirms.
CSV Convenience Helpers
These helpers read or write small CSV files in memory.
| Function | Returns | Description |
|---|---|---|
readCSV(path) |
list<list<string>> |
Read a whole CSV file |
writeCSV(path, rows) |
void |
Write rows to a CSV file |
let rows = io.readCSV("users.csv");
rows.push(["3", "Linus"]); # push mutates the list in place
io.writeCSV("users.csv", rows);
For large CSV files, use the streaming APIs in the csv module.
Common Patterns
Create a temporary workspace:
let root = io.tempDir("job-*");
defer io.remove(root);
let out = root + "/out.txt";
io.writeText(out, "result");
Safely update a file (atomic replace, no torn reads):
io.writeTextAtomic("settings.conf", newText);
io.chmod("settings.conf", 0o600);
Lock a file while processing:
func processLog(string path): void {
let f = io.open(path, "r");
defer io.close(f);
io.lock(f);
defer io.unlock(f);
let content = io.readAll(f);
# process content
}