d6 docs

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 one String; .read(n) reads up to n bytes and stops instead — needed for reading a fixed-size chunk (e.g. an HTTP request body of known Content-Length, as the packages/http package does) without also blocking on or consuming whatever the other side sends next on a still-open connection. On a noncanonical terminal configured with VMIN=0 and 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 exact read_line helper stdin's global gets() uses (same EOF/nil, CRLF-stripping, and no-line- length-cap behavior).
  • .write(value) stringifies value via stringify_value (same as print) 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 own File.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 → "." for dirname; a path made entirely of separators ("/") stays "/", never reduced to ""; a dotfile's own leading dot(s) never start an extname (".bashrc" → ""). suffix is stripped from basename only on an exact literal match (no Ruby-style ".*" wildcard support).

  • .absolute?(path) is path starting with / — no drive-letter or UNC handling, since this VM only targets POSIX platforms.

  • .expand_path(path, base = nil) resolves path to an absolute, lexically-normalized path: an absolute path is normalized as-is (base is then irrelevant); otherwise path is joined onto base (itself resolved against the current working directory first if base is relative) or directly onto the current working directory when base is 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.

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, raises IOError; nothing is created recursively.
  • Dir.mkdir_p(path, mode = 0o777) creates the directory and any missing parents, like mkdir -p, and returns the path. A directory that already exists is fine; a file in the way raises IOError.
  • File.copy(from, to) creates to exclusively 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 an Int (File.mode(p) & 0o077 == 0 for a private file).
  • File.chmod(path, mode) sets the permission bits and returns the path. It follows a symlink, and raises IOError when the file is missing or not yours.
  • File.size reads the size through a symlink and raises IOError when the path cannot be read. File.file? and File.symlink? read as false, not an error, for a missing path, like File.exist?. File.file?(p) && !File.symlink?(p) is "a regular file that is not a link".
  • Dir.delete removes an empty directory; Dir.sync flushes the directory itself (File.sync flushes 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.

Edit this page on GitHub