Invisible link to canonical for Microformats

ADR-031 Channel-Based Terminal Input (slug.io.stdin)


Status

Accepted

Context

Slug aims to keep the runtime small while favouring explicit composition through modules and structured concurrency.

Terminal input is a common requirement for CLI tools, REPLs, and scripts. Traditional approaches expose blocking primitives such as:

readLine()
scanf()
input()

However, Slug already provides:

Introducing a blocking input primitive would create a special case in the runtime that does not compose naturally with these primitives.

Instead, terminal input should behave like any other asynchronous event source and integrate with the existing concurrency model.

Additionally, Slug programs should behave naturally in Unix pipelines, supporting input from:

  • interactive terminals
  • pipes (|)
  • file redirection (<)

Decision

Terminal input will be exposed through a new module:

slug.io.stdin

stdin.readLines()

stdin.readLines() -> @chan(@struct(Full)|@struct(Empty))

Returns a shared singleton channel representing the process standard input stream.

Behaviour

  1. Each line read from stdin is sent to the channel as a Full{value: "..."} struct value.
  2. Line endings are normalized:

    • \n is stripped
    • \r\n becomes \n then stripped
  3. Empty lines are preserved as "".
  4. If input ends without a trailing newline, the final partial line is still emitted as a Full{} value before Empty{} is sent.
  5. When the input stream ends:

    • a single Empty{} value is sent
    • no further values are emitted.

Example stream:

Full{value: "a"}
Full{value: "b"}
Full{value: "c"}
Empty{}

Singleton Semantics

stdin.readLines() always returns the same underlying channel.

The runtime owns a single reader task that consumes the OS stdin stream and publishes values onto this channel.

Example:

val a = readLines()
val b = readLines()

Both a and b reference the same channel.

Programs should therefore treat stdin as a single-consumer stream.

If multiple modules require access to input, one module should own the stream and redistribute values explicitly.

Pipe Compatibility

The stdin stream may originate from:

  • an interactive terminal
  • a Unix pipe
  • redirected file input

Example:

printf "a\nb\nc\n" | slug app.slug

Produces the stream:

Full{value: "a"}
Full{value: "b"}
Full{value: "c"}
Empty{}

This behaviour ensures Slug programs compose naturally with standard shell pipelines.

Example Usage

Basic line processing:

var {*} = import(
	"slug.io.stdin",
	"slug.channel",
)

val loop = fn(in) {
	select {
		recv in
	} /> match {
		Full{value} => {
			println(">", value)
			recur(in)
		}
		Empty => println("done")
	}
}

loop(readLines())

Timeout interaction using existing channel functionality:

var {*} = import(
	"slug.std",
	"slug.io.stdin",
	"slug.channel",
)

val loop = fn(in) {
	defer onerror(e) match { Error{ type: "TimeoutError", data: {ms} } => println("Timeout after ms") }

	in
	/> recv(1000)
	/> match {
		msg @ Full{} => { println(">", msg); recur(in) }
		_ => println("done")
	}
}

loop(readLines())

This design allows terminal input to compose naturally with select.

Consequences

Positive

  • Maintains a consistent concurrency model by exposing terminal input as a channel
  • Allows stdin to compose naturally with select, timeouts, and other channel operations
  • Avoids introducing blocking IO primitives into the language runtime
  • Provides excellent Unix compatibility with pipes (|) and redirection (<)
  • Keeps the runtime implementation small and straightforward
  • Aligns with Slug’s event-stream model for external IO sources
  • Enables future IO sources (sockets, processes, signals) to follow the same pattern

Negative

  • Multiple consumers compete for values because stdin is a shared singleton channel
  • Programs must coordinate ownership if multiple modules require access to input
  • Input cannot be rewound once consumed, mirroring standard OS stream semantics

Neutral

  • Interactive programs typically process input via recursion rather than loops
  • Channel consumers must handle the Full{} / Empty{} value pattern explicitly
  • stdin.readLines() differs from fs.readLines(path) by returning a channel rather than a list