I/O library design¶
See I/O library for the full predicate catalogue and platform caveats, and
Use the I/O library for a hands-on walkthrough. This page is about why :io-lib is
built the way it is: what it reuses from :solve rather than reinventing, why stream-opening is split per
platform, and why some ISO predicates are registered but deliberately left unimplemented.
Reusing Library and Channel, adding only Url¶
:io-lib is, structurally, nothing but a Library — a Signature-indexed map of
Primitives, aliased "prolog.io" (see IOLib, it.unibo.tuprolog.solve.libs.io). It contributes no new
resolution mechanism: every predicate is written against Channel/InputChannel/OutputChannel from :solve
(see Solver design), the same abstraction
write/1/read/1 and friends already use for a solver's standard streams. That reuse is what lets open/3,4
register a new named channel (backing a file or a remote resource) that every other I/O predicate then talks to
exactly as it would talk to standard input/output — IOPrimitiveUtils.ensuringArgumentIsChannel and friends
don't care whether a channel came from SolverBuilder.standardOutput or from open/3.
The one genuinely new abstraction :io-lib introduces is Url (it.unibo.tuprolog.solve.libs.io.Url): a parsed
source/sink locator, since the ISO SourceSink argument of open/3,4/consult/1 needs to name what to open
before a Channel can exist at all. Url is deliberately minimal — protocol/host/port/path/query, plus
readAsText/readAsByteArray for the eager, whole-resource-at-once reads that consult/1 and JS's non-Node
paths need:
interface Url {
/** The scheme this [Url] uses, e.g. `"file"`, `"http"`, `"https"`. */
@JsName("protocol")
val protocol: String
/** The host component of this [Url], or the empty string for [isFile] URLs. */
@JsName("host")
val host: String
/** The path component of this [Url], e.g. `/path/to/resource`. */
@JsName("path")
val path: String
/** The port component of this [Url], or `null` if unspecified. */
@JsName("port")
val port: Int?
/** The query component of this [Url] (everything between `?` and an optional `#anchor`), or `null` if absent. */
@JsName("query")
val query: String?
Why Url is platform-specific¶
There is no single Kotlin Multiplatform URL-parsing API, and "open this local path for streaming" means entirely
different things on the JVM (java.io/java.nio), on Node (its own fs module), and in a browser (no real file
system at all). Rather than fighting that with a lowest-common-denominator abstraction, Url and the handful of
functions that build/use it (parseUrl, fileUrl, remoteUrl, openInputChannel, openOutputChannel,
toLocalPath — all in UrlUtils.kt) are Kotlin expect/actual declarations, each platform free to implement
however makes sense locally:
- JVM (
JvmUrl,UrlUtilsJvm.kt): thinly wrapsjava.net.URL. Any protocoljava.net.URLunderstands opens a real, lazily-streamedInputStream/OutputStream;openInputChannel/openOutputChannelnever buffer a resource fully in memory. - JS (
JsUrl,UrlUtilsJs.kt,RemoteAndBrowserIO.kt): there is nojava.net.URLto wrap, soJsUrlhand-parses via the WHATWGURLbinding shared by Node and browsers. Behavior then forks again on whether the runtime is Node (isNode,it.unibo.tuprolog.Info.PLATFORM): only Node gets real, streamed local-file access (via Okio'sNodeJsFileSystem, see below); a browser instead reads/writes local ("file") resources throughwindow.localStorage, and any remote resource on either JS runtime is fetched eagerly, in full via thesync-requestnpm package, since Okio is not an HTTP client and theSolverAPI is synchronous end-to-end (no async I/O story to plug a real streaming HTTP client into). This is a real, user-visible asymmetry:open/3on a large remote file streams lazily on the JVM but buffers the whole thing in memory on JS — see I/O library for the consolidated caveat table.
Both platform Urls independently guard against a Windows-specific footgun: a native Windows path
(C:\Users\...) parses "successfully" under a generic URL parser as a URL with a single-letter scheme (the drive
letter), so both JvmUrl's underlying java.net.URI (via isAbsolute checks) and JsUrl's constructor
explicitly reject that shape, forcing Url.Companion.of's file://-prefix fallback to take over instead.
Why Okio¶
Local-file access used to be hand-rolled per platform. This branch moved it onto
Okio, Square's multiplatform I/O library, for the local (file://) case on
both JVM and Node:
kotlin {
sourceSets {
commonMain {
dependencies {
api(project(":solve"))
api(project(":parser-theory"))
implementation(project(":parser-impl"))
implementation(libs.okio)
}
}
commonTest {
dependencies {
implementation(project(":test-solve"))
implementation(project(":solve-classic"))
implementation(project(":solve-streams"))
implementation(libs.okio.fakefilesystem)
}
}
named("jsMain") {
dependencies {
// Okio has no synchronous file system for JS; NodeJsFileSystem covers local files on Node.
implementation(libs.okio.nodefilesystem)
// Okio is not an HTTP client: remote consult/1 still needs a synchronous HTTP call on JS,
// which the (synchronous) Solver API requires. Kept until the Solver gets an async story.
implementation(
npm(
"sync-request",
libs.versions.npm.syncRequest
.get(),
),
)
}
}
}
}
Two things this buys, beyond not hand-rolling buffered readers/writers per platform:
- A single testable seam.
LocalFileSystem(it.unibo.tuprolog.solve.libs.io) wraps whicheverokio.FileSystemis current behind a swappablevar, defaulting toplatformFileSystem(FileSystem.SYSTEMon the JVM,NodeJsFileSystemon JS) but replaceable in tests with Okio'sFakeFileSystem— an in-memory implementation that behaves like a real one without touching disk. - Uniform channel wrapping.
SinkOutputChannel/SourceInputChannel(it.unibo.tuprolog.solve.libs.io.channel) adapt an OkioBufferedSink/BufferedSourceintoOutputChannel<String>/InputChannel<String>once, shared by both platforms, rather than each platform separately gluing a native stream type into theChannelcontract.
Okio still isn't an HTTP client (hence sync-request staying a JS dependency for remote reads) and has no
synchronous file system for browsers (hence the window.localStorage fallback there) — it only replaced the
local-file half of the picture.
Errors: platform failures become ISO errors, not Throwables¶
A Primitive must ultimately fail or throw a LogicError — never let a raw platform exception escape. :io-lib
funnels every platform-level failure through two small wrapper exceptions in
it.unibo.tuprolog.solve.libs.io.exceptions:
InvalidUrlException— a string didn't parse into aUrl(raised byparseUrl/Url.Companion.of). Carries enough context (viatoLogicError(context, signature, culprit, index)) to become an argument-indexed ISOtype_error(url, Culprit), since the offending argument is known at the call site (open/3,4,consult/1).IOException— a resource that did parse as aUrlstill couldn't be read/written (missing file, unreachable host, writing to a non-fileUrl). UnlikeInvalidUrlException, this carries no argument context — 2P-Kt doesn't attempt to classify why the platform I/O call failed any further — sotoLogicError(context)always produces an uncaughtSystemError, rather than a more specificexistence_error/2/permission_error/3.
Deliberately unimplemented predicates¶
IOLib registers get_byte/1,2, put_byte/1,2, peek_byte/1,2, close/2, char_conversion/2 and
current_char_conversion/2 for ISO conformance (a stream_property/2 query, or program relying on their mere
existence, should not see existence_error(procedure, ...)), but every one of them unconditionally raises a
SystemError when actually called. This is a deliberate design choice, not an oversight:
- Only text streams exist here.
stream_property/2always reportstype(text)(seeIOPrimitiveUtils.propertiesOf); there is no binary-stream mode forget_byte/put_byte/peek_byteto operate on, so implementing them "for real" would mean adding a whole second stream kind this library otherwise has no use for. - No character-conversion table exists either, so
current_char_conversion/2(which would enumerate it) has nothing to report even ifchar_conversion/2were implemented. close/2's only extra feature overclose/1is options likeforce(true), which don't change behavior when there's nothing platform-specific to force.
Raising loudly (rather than silently no-op'ing or approximating) is the point: TestUnsupportedIOPrimitives
exists specifically to lock this behavior in, so a future partial implementation doesn't start silently returning
wrong results in place of a clear, obvious-at-a-glance gap.