What do you think about Io.print/Io.println for stdout and Io.readLine/Io.readAlloc for stdin

Printing to stdout and reading lines from stdin are the most common I/O operations in CLI programs, yet they currently require several lines of ceremony. It would be great to avoid that and instead add four helper functions to “Io”:

pub fn print(io: Io, comptime fmt: []const u8, args: anytype) File.Writer.Error!void {
    var buf: [1024]u8 = undefined;
    var w = File.stdout().writer(io, &buf);
    try w.interface.print(fmt, args);
    try w.flush();
}

pub fn println(io: Io, comptime fmt: []const u8, args: anytype) File.Writer.Error!void {
    try io.print(fmt ++ "\n", args);
}

pub const ReadLineError = File.Reader.Error || error{StreamTooLong};

pub fn readLine(io: Io, buffer: []u8) ReadLineError!?[]const u8 {
    var r = File.stdin().reader(io, buffer);
    const line = (try r.interface.takeDelimiter('\n')) orelse return null;
    return std.mem.trimRight(u8, line, "\r");
}

pub fn readAlloc(io: Io, gpa: Allocator, limit: Limit) LimitedAllocError![]u8 {
    var r = File.stdin().reader(io, &.{});
    return r.interface.allocRemaining(gpa, limit);
}

And the example:

pub fn main(init: std.process.Init) !void {
    try init.io.println("Hello, World!");
    var line_buf: [4096]u8 = undefined;
    if (try init.io.readLine(&line_buf)) |line| {
        try init.io.println("echo: {s}", .{line});
    }
}
1 Like

I kind of agree with this, especially with the nuances that reader/writer will prefer positional mode and that can cause problems if you redirect stdout to a file. Each writer will start from position 0 and keep overwriting. These always need to be created as explicitly streaming. I’m not sure about the naming, but I think a wrapper like this would be useful. Bonus points if it recovers the error from the reader/writer, so what comes back is e.g. error.Canceled, not error.WriteFailed.

This is an incorrect implementation. If you have the plaintext file input.txt

abc
123

and redirect to stdin using ./program < input.txt, then every individual call to readLine() will always return abc. If you have a loop like while (try readLine(init.io, &line_buf)) |line|, it will never terminate on its own.

This is because File.stdin().reader(io, buffer) tries to use positional reading before falling back to streaming reading, and when stdin is redirected, positional reading is possible.

On the other hand, if you swap out .reader(io, buffer) for .readerStreaming(io, buffer), now the first call will return abc but all future calls null. Why? Because the first call reads the full contents of input.txt (abc\n123\n) into its buffer, advancing the global seek position of stdin, but then as it returns abc, everything past it is effectively discarded and lost. You would need to manually seek using the global seek position to correct this state.

The point I’m trying to make by dissecting this faulty implementation is that while the boilerplace needed to read from stdin might seem pointless, it’s not only necessary in order to not lose important reader state, but also useful because it makes the state fully explicit and obvious to the reader.


As for stdout, one of the main arguments against including something like print or println is that unnecessarily flushing after every print statement is highly inefficient. Programmers are naturally lazy. If std was to expose a simple one-liner API for printing to stdout, then most people will naturally reach for that API instead of the more cumbersome option of initializing a writer and manually flushing. As a result, the software that is delivered to end users will be worse.

If you want to print for debugging reasons, you have std.debug.print. If you want to print diagnostics while a program is running, you have std.log. Stdout deserves a bit more thought and care because it’s often used as a part in | piping. Writing to stdout should be approached in the same way as writing to any regular file.

5 Likes

What if we created a separate “Console” structure that maintains its own state? That would solve the problem you mentioned:

pub const Console = struct {
    io: Io,
    out: File.Writer,
    in: File.Reader,
    out_buf: [4096]u8 = undefined,
    in_buf: [4096]u8 = undefined,

    pub fn init(io: Io) Console {
        var c: Console = .{ .io = io, .out = undefined, .in = undefined };
        c.out = File.stdout().writerStreaming(io, &c.out_buf);
        c.in = File.stdin().readerStreaming(io, &c.in_buf);
        return c;
    }

    pub fn print(c: *Console, comptime fmt: []const u8, args: anytype) File.Writer.Error!void {
        try c.out.interface.print(fmt, args);
    }

    pub fn println(c: *Console, comptime fmt: []const u8, args: anytype) File.Writer.Error!void {
        try c.print(fmt ++ "\n", args);
    }

    pub fn flush(c: *Console) File.Writer.Error!void {
        try c.out.flush();
    }

    pub fn readLine(c: *Console) (File.Reader.Error || error{StreamTooLong})!?[]u8 {
        const line = (try c.in.interface.takeDelimiter('\n')) orelse return null;
        return std.mem.trimRight(u8, line, "\r");
    }

    pub fn deinit(c: *Console) !void {
        try c.flush();
    }
};

And use it in a similar way:

var con = std.Io.Console.init(init.io);
defer con.deinit();

try con.println("Hello!");
try con.flush();

That is self-referential, the reader/writer hold pointers to the buffers, so it cant be copied safely, e.g your init function.

You can use the init-in-place pattern to remove the copy (it still cant be copied) from the init function:

    pub fn init(c: *Console, io: Io) void {
        c = .{ .io = io, .out = undefined, .in = undefined };
        c.out = File.stdout().writerStreaming(io, &c.out_buf);
        c.in = File.stdin().readerStreaming(io, &c.in_buf);
    }

// used like so
var c: Console = undefined;
c.init(io);

Or you can take the buffers as parameters so that it is not self-referential in the first place.

I probably would not use such a minor wrapper over a reader/writer, just my opinion, not particularly relevant.

1 Like

Ignoring any issues with the implementation, I don’t see where the great value is. You’re only saving maybe two lines of code in exchange for hiding important decision-making opportunities that are important for developers to consider the pros and cons of:

// Decision #1: What is the size of the reader buffer?
// The simple 'takeDelimiter' approach only works for lines that fit inside the buffer.
// Unbounded inputs require different strategies.
var r_buf: [4096]u8 = undefined;
var r = std.Io.File.stdin().readerStreaming(init.io, &r_buf);

// Decision #2: what is the size of the writer buffer?
// The smaller the buffer, the more often a syscall needs to be made, which is slow.
// But a larger buffer requires more memory
// and delays interactive output unless explicitly flushed.
var w_buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writerStreaming(init.io, &w_buf);

// Decision #3: Are input lines terminated by LF, CRLF or both?
if (try r.interface.takeDelimiter('\n')) |line| {
    // Decision #4: Are output lines terminated by LF or CRLF?
    try w.interface.print("echo: {s}\n", .{line});
    try w.interface.flush();
}

If you frequently need to e.g. trim leftover \r or append \n to format strings it’s trivial to write your own functions for doing so.

2 Likes

Thanks everyone for the insightful feedback and for taking the time to explain the rationale.