Local I/O
I/O and native services · Next: Networking and signals
This guide covers standard streams, files, and path manipulation. See the I/O index for networking, databases, time, and processes.
stdout: print/puts
print("hello, ")
puts("world")
print(value) writes value's stringified form to stdout with no
trailing newline; puts(value) writes it with one. Both take exactly one
argument and both return nil.
Stringification reuses the exact mechanism string interpolation (#{})
and the DIAMOND_OP_TO_STRING opcode already use, via a shared
stringify_value helper in src/vm.c: a String value passes through
unchanged; an instance with a to_s() method has it called (and its
return value must be a String, or a rescuable TypeError); everything
else falls back to the same formatter used by string interpolation
(nil, true/false, integers, and [...]/{...} for arrays/hashes).
A to_s with the wrong arity raises a rescuable ArgumentError, exactly
as calling it any other way would.
print/puts are recognized in the compiler's parse_name, the same
place Fiber.new and redefine_method are — gated on the identifier not
already being a local or a user-defined function, so def print(x) ... end shadows the builtin entirely (no reserved keyword). They compile to
a single DIAMOND_OP_PRINT dest, source, flags instruction; flags is a
compile-time-constant byte of DIAMOND_PRINT_* bits (0 for print, 1
for puts), not a runtime value.
puts (newline=1) flushes stdout after writing; print (newline=0)
does not. stdout is fully buffered, not line-buffered, once it isn't a
terminal — redirected to a file, a pipe, whatever a test harness or
> log capture uses — so without this, a puts("ready") written right
before a program blocks in a native call (.accept(), IO.poll, a
UDPSocket#receive loop) could sit in the buffer indefinitely: nothing
forces a flush until the buffer fills or the process exits, and a
process blocked waiting for someone to see its own "ready" line is
exactly the case that never reaches either. Found this the hard way
while building the signals test below (see its own section) — a
"server prints ready, test harness polls the captured output for that
line" pattern already used throughout tests/run.sh and the
packages/* test scripts, which happened to keep working anyway
wherever the thing being waited on (bind, mostly) completes fast
enough that the lack of an early flush didn't matter, until it did.
print stays fully buffered — matching ordinary line-buffered-on-a-
terminal behavior unconditionally (rather than only when isatty())
only for the newline-terminated case, so building up a line
incrementally via repeated print calls doesn't pay a flush cost per
fragment.
A write failure — most commonly EPIPE from a reader that closed its end
of a pipe — raises a rescuable IOError ("write error: %s" with
strerror(errno)), the same convention File#write already uses,
instead of being silently dropped or killing the process outright.
diamond_vm_init ignores SIGPIPE process-wide (not just for the TLS
write path elsewhere in this document), so a write past a closed reader
always surfaces as a plain, catchable error:
begin
loop do
puts("line")
end
rescue e: IOError
# the reader went away
end
stderr: warn
warn("config file not found; using defaults")
warn(value) writes value's stringified form and a newline to stderr,
using the same stringification as puts, and returns nil. Use it for
diagnostics that shouldn't mix into a program's stdout, which may be piped
somewhere else. It compiles to the same DIAMOND_OP_PRINT instruction with
the stderr flag set, and like print/puts a local or user-defined function
named warn takes precedence over the built-in.
Terminals: IO.tty?
color = IO.tty?(:stdout) && ENV["NO_COLOR"] == nil
puts(color ? "\e[32mok\e[0m" : "ok")
IO.tty?(stream) is true when :stdin, :stdout or :stderr is a
terminal (isatty), and false when it is a file or a pipe -- the usual
test before printing colour or prompting for input. Any other name raises
TypeError.
Debug printing: p
total = p(price * quantity) + shipping # prints 42, then adds
p(value) writes value.inspect() and a newline to stdout and evaluates to value, so it can wrap
any expression while you debug. Strings print quoted with escapes and nil prints as nil, unlike
puts. It takes exactly one argument (no p a, no p(a, b)). It is the last resort for the name: a
local, a function, or a method of the surrounding class or its superclass named p takes precedence.
stdin: gets()
name = gets()
puts("hi #{name}")
gets() takes no arguments and reads one line from stdin, returning it
as a String with the trailing line ending stripped — both \n and
\r\n are handled, and stripping happens on the accumulated line rather
than per underlying read, so it's correct regardless of where an
internal buffer boundary happens to fall relative to the ending. Returns
nil only when zero bytes were read before EOF; a final line with no
trailing newline still returns its content, matching Ruby's gets.
Compiles to a single DIAMOND_OP_GETS dest instruction. Recognized with
the same shadowing precedent as print/puts.
On native builds, Supervisor#stop(timeout_ms) and sibling restarts interrupt
a worker blocked in gets(), including one waiting behind another reader.
Readers share one serialized stdin stream; an interrupted reader leaves any
partial line for the next gets() to finish. The inherited descriptor's flags
are unchanged. The terminal debugger uses the same reader. Embedders should not
mix these reads with buffered C stdio reads on stdin, since stdio read-ahead
is invisible to descriptor readiness checks. WebAssembly keeps its direct
stdio reader and does not support threads or supervisors.
Line length is not capped — reading grows a buffer across as many
underlying fgets calls as a line needs, the same growable-buffer
pattern (StringBuilder) already used for value formatting elsewhere
in src/vm.c.
Files: File.open/.read/.gets/.write/.close
f = File.open("data.txt", "w")
f.write("hello, ")
f.write("world")
f.close()
g = File.open("data.txt", "r")
g.read() # => "hello, world"
g.close()
File.open(path, mode) opens a file via the C fopen(path, mode)
convention directly — mode is passed through unvalidated ("r",
"w", "a", "r+", and so on all work exactly as they would in C; an
invalid mode fails the same way a missing path does). A failed open
raises a rescuable IOError with strerror(errno) in the message, the
same phrasing the CLI's own require/file-loading errors already use
(cannot open '<path>': <reason>).
A File value is a new GC-managed heap object kind
(DIAMOND_OBJECT_FILE), a thin wrapper around a FILE * — the same
shape as Fiber's DiamondFiberHandle around a DiamondFiber *.
Unlike Fiber, nothing inside a DiamondFileHandle references another
d6 value, so mark_object needs no dedicated branch for it; sweeping
an unreached handle whose stream is still open calls fclose on it as a
safety net, the same role sweep-time cleanup plays for an unclosed
Fiber's native stack.
File.open is recognized in the compiler the same way Fiber.new is
(shadowable by a local or a top-level function of the same name),
compiling to a single
DIAMOND_OP_FILE_OPEN dest, path, mode instruction. .read()/.gets()/
.write(value)/.close() are native DIAMOND_OP_INVOKE dispatch on a
DIAMOND_OBJECT_FILE receiver, the same mechanism Fiber's .resume/
.status/.alive? use:
.read()reads all remaining bytes from the current position to EOF as oneString;.read(n)reads up tonbytes and stops instead — needed for reading a fixed-size chunk (e.g. an HTTP request body of knownContent-Length, as thepackages/httppackage does) without also blocking on or consuming whatever the other side sends next on a still-open connection. On a noncanonical terminal configured withVMIN=0and a read timeout, an empty result can mean the timeout expired. Later reads on the same handle still accept input; this transient result does not stick as EOF. Ordinary file and socket EOF behavior is unchanged..gets()reads one line, sharing the exactread_linehelper stdin's globalgets()uses (same EOF/nil, CRLF-stripping, and no-line- length-cap behavior)..write(value)stringifiesvalueviastringify_value(same asprint) and writes it..close()is idempotent — closing an already-closed handle is a no-op, not an error.
Any operation other than .close() on an already-closed handle, or a
genuine read/write failure (checked via ferror, not just a short
return value), raises a rescuable IOError.
File paths: File.join/.dirname/.basename/.extname/.absolute?/.expand_path
File.join("a", "b", "c") # => "a/b/c"
File.join("a/", "/b/", "c") # => "a/b/c" -- redundant separators collapsed
File.dirname("/a/b/c") # => "/a/b"
File.basename("/a/b/c.rb") # => "c.rb"
File.basename("/a/b/c.rb", ".rb") # => "c"
File.extname("archive.tar.gz") # => ".gz"
File.absolute?("/a/b") # => true
File.absolute?("a/b") # => false
File.expand_path("../b", "/a/x") # => "/a/b"
File.expand_path("relative/path") # => cwd + "/relative/path"
Pure String manipulation — none of these touch the filesystem or
require path to actually exist, except .expand_path calling
getcwd() when resolving a relative path with no base (or a
relative base) given. All six are recognized in the compiler the same
way File.open/Fiber.new are (shadowable by a local or a top-level
function of the same name); .join's variable-length argument list
compiles to DIAMOND_OP_FILE_JOIN dest, base, count (the same
contiguous-register-run shape Thread.new's own argument list uses),
and the other five share one DIAMOND_OP_FILE_PATH dest, arg1, arg2, selector instruction (the DIAMOND_OP_MATH_UNARY/_BINARY "one opcode
a selector byte" shape, reused here for
.dirname/.extname, taking one argument, and.basename/.expand_path, taking an optional second)..join(*parts)skips empty-string parts entirely and collapses a redundant/at each seam — not bug-for-bug identical to Ruby's ownFile.join(which treats a leading""part as still contributing a separator), a deliberately simpler and more predictable rule instead..dirname(path)/.basename(path, suffix = nil)/.extname(path)follow Ruby's own rules: no separator at all →"."fordirname; a path made entirely of separators ("/") stays"/", never reduced to""; a dotfile's own leading dot(s) never start anextname(".bashrc"→"").suffixis stripped frombasenameonly on an exact literal match (no Ruby-style".*"wildcard support)..absolute?(path)ispathstarting with/— no drive-letter or UNC handling, since this VM only targets POSIX platforms..expand_path(path, base = nil)resolvespathto an absolute, lexically-normalized path: an absolutepathis normalized as-is (baseis then irrelevant); otherwisepathis joined ontobase(itself resolved against the current working directory first ifbaseis relative) or directly onto the current working directory whenbaseis nil. Normalization then walks the combined path dropping empty and"."segments and popping the previous real segment on".."— kept literally only when there is nothing left to pop, so this can never climb above the root, matching Ruby.
Filesystem: File.exist?/.directory?/.delete, Dir.entries
File.exist?("README.md") # => true -- a file, directory, or anything else
File.exist?("/no/such/path") # => false -- not an error
File.directory?(".") # => true
File.directory?("README.md") # => false
File.directory?("/no/such/path") # => false -- not an error
File.delete("/tmp/scratch.txt") # => true, or false if already gone
Dir.entries("/tmp") # => ["a.txt", "b.txt", ...] -- excludes "." and ".."
Unlike the pure-string path helpers above, these three touch the
filesystem. File.exist?(path) and File.directory?(path) are stat()-backed; a stat()
failure (the path doesn't exist, permission denied, ...) reads as
false, not an error, matching Ruby's own File.directory? and
letting it sit directly in a condition with nothing to rescue.
File.delete(path) removes a file (remove()); a file that's already
gone returns false rather than raising (treated as a normal outcome,
not something to rescue around), but a genuine failure (permission
denied, path is a non-empty directory, ...) raises a rescuable
IOError, the same convention File.open uses. Dir.entries(path)
lists one directory's immediate entries as an Array[String]
(opendir/readdir/closedir, excluding "."/"..") — no
recursion, no globbing; a directory that doesn't exist (or otherwise
fails to open) raises a rescuable IOError.
All three are recognized in the compiler the same way File.open/
Fiber.new are (shadowable by a local or a top-level function of the
same name). See Sandbox mode for how --sandbox gates
all three under its filesystem capability.
Files and directories: File.copy, File.size, File.sha256, File.file?, File.symlink?, Dir.mkdir, Dir.delete, Dir.sync
Dir.mkdir("backup", 0o700) # => "backup"; IOError if it exists or the parent is missing
File.copy("db.sqlite", "backup/db.sqlite") # => the destination path
File.size("backup/db.sqlite") # => bytes, as an Int
File.file?("backup/db.sqlite") # => true for a regular file (a symlink to one counts)
File.symlink?("backup/db.sqlite") # => true only for a symlink itself, not what it points at
Dir.sync("backup") # fsync the directory so its entries are durable
Dir.delete("backup") # rmdir: an empty directory only
Dir.mkdir(path, mode = 0o777)creates one directory (the mode is subject to the umask). An existing path, or a missing parent, raisesIOError; nothing is created recursively.Dir.mkdir_p(path, mode = 0o777)creates the directory and any missing parents, likemkdir -p, and returns the path. A directory that already exists is fine; a file in the way raisesIOError.File.copy(from, to)createstoexclusively with the source's permission bits (subject to the umask) and copies the bytes. An existing destination is never replaced (IOError), and a failed copy removes the partial file. The source must be a regular file.File.sha256(path)is the lowercase hex SHA-256 of the file, read in chunks so a large file never has to fit in memory (Digest.sha256(File.read(path))gives the same digest).File.mode(path)is the permission bits as anInt(File.mode(p) & 0o077 == 0for a private file).File.chmod(path, mode)sets the permission bits and returns the path. It follows a symlink, and raisesIOErrorwhen the file is missing or not yours.File.sizereads the size through a symlink and raisesIOErrorwhen the path cannot be read.File.file?andFile.symlink?read asfalse, not an error, for a missing path, likeFile.exist?.File.file?(p) && !File.symlink?(p)is "a regular file that is not a link".Dir.deleteremoves an empty directory;Dir.syncflushes the directory itself (File.syncflushes a file and its parent).
All are gated by the filesystem sandbox capability.
Atomic creation: File.publish(path, bytes)
Publishes a complete String as a new file and returns its path. Both arguments
are required Strings; the path must be nonempty and contain no NUL. Binary
content, including NUL bytes, is preserved. Existing files, directories, and
symlinks at the destination cause IOError and are never replaced.
The parent directory must exist and be trusted. This operation creates a
mode-0600 temporary file there, writes and synchronizes it, uses a hard link
for atomic publication, removes the temporary name, and synchronizes the
parent directory before returning. It requires a filesystem supporting hard
links and directory fsync. It is gated by the filesystem sandbox capability.
An interrupted process may leave .diamond-publish-* temporary files. Remove
these only with writers stopped. Errors after the link can leave a complete
published destination; callers must inspect it before retrying. Durability
is subject to the filesystem and storage device honoring synchronization.
Flushing writes: File#flush
file.flush() hands everything written so far to the operating system
(fflush) and returns the file, so another reader -- or the same program
after a crash -- sees it without waiting for close(). It doesn't force
the data to disk; File.sync(path) does that. A failure raises IOError.
Atomic replacement: File.rename(from, to)
Renames from to to with rename(2) and returns to. An existing file at
to is replaced atomically: a reader sees the old contents or the new ones,
never a partial write. That makes it the way to update a file safely --
write the new contents to a temporary file in the same directory, then
rename it over the original. Both paths must be nonempty Strings without NUL
(TypeError otherwise) and on the same filesystem; any failure (missing
source, permission denied, a cross-device rename, ...) raises IOError. It
is gated by the filesystem sandbox capability.
temporary = "#{path}.tmp"
file = File.open(temporary, "w")
file.write(contents)
file.close()
File.rename(temporary, path)
Recovery synchronization: File.sync(path)
Synchronizes an existing regular file and its containing directory, returning
its path. It rejects empty or NUL-containing paths with TypeError, and missing
files, symlinks, nonregular files, and synchronization failures with IOError.
The directory must be trusted. This is gated by the filesystem capability.
Registry retries use it after verifying an orphan blob and before committing a
new database reference. This operation does not itself validate file contents.