07. I/O and files¶
I/O crosses from a Hara program into its host environment. File operations are effects, require authority, use bytes, and return promises.
The file namespace can exist even when the runtime does not grant file access.
Learning goals¶
By the end of this lesson, you can:
- Explain why file access is a capability.
- Resolve a child path beneath an allowed root.
- Read a file as bytes.
- Decode file bytes as UTF-8 text.
- Transform text without I/O.
- Encode and write output bytes.
- Compose file operations with promises.
- Keep errors and cleanup at an explicit boundary.
I/O is an effect¶
A pure function returns a value from its arguments:
(defn normalize-line [line]
(str/to-lower (str/trim line)))
A file operation asks the host environment to read or change an external resource:
(file/read "data/input.txt")
The file can change without the Hara source changing. The operation can also fail because of authority, availability, path, or host errors.
Keep pure transformation and I/O in separate functions.
File access needs authority¶
The file/ alias is available by default. Availability does not grant authority.
A file call can fail because:
- the embedding runtime does not support file I/O;
- the runtime did not grant file authority;
- the path is outside the allowed boundary;
- the file does not exist;
- the host rejects the operation.
The native CLI grants file authority explicitly with --allow-file. A JVM embedding grants authority through Graal IOAccess.
Do not treat a missing capability as a missing namespace.
Resolve paths beneath a root¶
Use file/resolve to resolve a child path beneath a supplied root:
(def project-root ".")
(def input-path
(file/resolve project-root "data/input.txt"))
(def output-path
(file/resolve project-root "data/output.txt"))
The result is a normalized path string.
Keep the root and child path separate in configuration:
(def file-config
{:file/root "."
:file/input "data/input.txt"
:file/output "data/output.txt"})
Resolve paths at the I/O boundary:
(defn input-path [config]
(file/resolve
(:file/root config)
(:file/input config)))
This makes the path authority boundary visible.
Read a file¶
file/read returns a promise that settles with bytes:
(def input-promise
(file/read input-path))
The immediate result is a promise, not file contents.
Transform the settled bytes with promise/then:
(def text-promise
(promise/then
input-promise
(fn [input-bytes]
(str/decode input-bytes))))
The decode step belongs after the file bytes arrive.
Inspect a result in the REPL¶
A Hara promise supports dereference in environments where blocking inspection is appropriate:
(deref text-promise)
Use blocking dereference for small REPL experiments and tests. Do not make it the default architecture for event-loop or interactive code.
Use promise composition in application code.
Split text into lines¶
Keep line parsing pure:
(defn text-lines [text]
(str/split-lines text))
Build a line pipeline:
(defn transformed-lines [text]
(->> (text-lines text)
(filter non-empty-line?)
(map normalize-line)))
Serialize the complete result:
(defn lines->text [lines]
(str (str/join "\n" lines) "\n"))
The trailing newline is an output-format decision. Keep it in the serializer.
Transform file bytes without I/O¶
Create one pure bytes-to-bytes function:
(defn transform-document-bytes [input-bytes]
(-> input-bytes
(str/decode)
(transformed-lines)
(lines->text)
(str/encode)))
This function can be tested with in-memory values:
(def sample-input
(str/encode " Alpha \n\n Beta \n"))
(str/decode
(transform-document-bytes sample-input))
; => "alpha\nbeta\n"
No file authority is required for this test.
Write a file¶
file/write accepts bytes and returns a promise:
(def output-bytes
(str/encode "alpha\nbeta\n"))
(def write-promise
(file/write output-path output-bytes))
Do not pass a string directly. Encode the text first.
Use promise/then to handle successful completion:
(promise/then
write-promise
(fn [result]
{:write/status :status/complete
:write/result result}))
Use the actual file contract for the precise write result. Do not assume that it returns the bytes value.
Compose read, transform, and write¶
Build one promise chain:
(defn transform-file [input-path output-path]
(promise/then
(file/read input-path)
(fn [input-bytes]
(file/write
output-path
(transform-document-bytes input-bytes)))))
The callback returns the write promise. Promise adoption makes the returned chain wait for the write operation.
The control flow is:
read promise
-> input bytes
-> pure transformation
-> output bytes
-> write promise
-> write result
Record progress in the atom¶
Keep state updates at the I/O boundary:
(defn start-file-run! []
(reset! run initial-run)
(swap! run start-run))
(defn complete-file-run! []
(swap! run finish-run))
(defn fail-file-run! [error]
(swap! run
(fn [state]
(assoc state
:run/status :status/failed
:run/error error))))
Wrap the file chain:
(defn run-file! [input-path output-path]
(start-file-run!)
(-> (transform-file input-path output-path)
(promise/then
(fn [result]
(complete-file-run!)
result))
(promise/catch
(fn [error]
(fail-file-run! error)
(throw error)))))
The pure transformation still has no knowledge of the atom.
Cleanup with promise/finally¶
Use promise/finally for state or resource cleanup that must run after either outcome:
(defn run-file-with-cleanup! [input-path output-path]
(promise/finally
(run-file! input-path output-path)
(fn []
(swap! run assoc :run/current nil))))
Do not use finally to hide the original failure.
Use a coroutine for sequential async workflow¶
A coroutine can express a sequence of promise waits:
(ns intro.file-workflow
(:require [std.foundation.coroutine :as coroutine]))
(defn make-file-worker [input-path output-path]
(coroutine/create
(fn []
(let [input-bytes
(coroutine/await
(file/read input-path))]
(coroutine/yield
{:phase :phase/read
:bytes (bytes/count input-bytes)})
(let [output-bytes
(transform-document-bytes input-bytes)]
(coroutine/await
(file/write output-path output-bytes))
{:phase :phase/complete
:bytes (bytes/count output-bytes)})))))
The coroutine makes each await point explicit. The promise chain remains the simpler choice for a short read-transform-write flow.
Use the coroutine when the workflow must pause, expose intermediate values, and resume through several stages.
File I/O is byte-oriented¶
The current file boundary reads and writes bytes.
This design avoids hidden text assumptions:
- a text program chooses UTF-8 decoding;
- a binary program keeps bytes;
- a protocol parser can inspect exact byte values;
- a writer chooses the serialized format before the host call.
Do not decode a binary file merely because the file API returned bytes.
Streaming and whole-file I/O¶
The iterator lesson introduced demand-driven processing. The basic file/read operation returns the file bytes through one promise.
For a small file, whole-file processing is direct:
read all bytes -> transform -> write all bytes
For a large or continuous source, use a provider that exposes chunks or an iterator. Keep the same design:
resource-backed iterator
-> bounded transforms
-> explicit consumer
-> explicit close
Do not claim that whole-file file/read is streaming.
Complete the course project¶
Create configuration:
(def config
{:file/root "."
:file/input "data/input.txt"
:file/output "data/output.txt"})
Resolve both paths:
(def resolved-input
(file/resolve
(:file/root config)
(:file/input config)))
(def resolved-output
(file/resolve
(:file/root config)
(:file/output config)))
Start the operation:
(def operation
(run-file-with-cleanup!
resolved-input
resolved-output))
Inspect the returned promise and run state:
operation
@run
When blocking inspection is suitable:
(deref operation)
@run
Practice loop¶
- Transform sample bytes without file access.
- Predict the output text.
- Resolve a path beneath a test root.
- Run a read with file authority disabled and inspect the error.
- Grant file authority in the runtime.
- Run the file pipeline.
- Inspect the output bytes and run state.
- Change the pure transformation and run it again.
Common mistakes¶
Assuming namespace access grants file authority¶
The file/ alias can exist while calls remain denied.
Passing text directly to file/write¶
Encode the text as bytes.
Decoding every file¶
Decode only when the file contract identifies text.
Blocking on every promise¶
Use deref for controlled REPL or test inspection. Compose promises in application code.
Mixing transformation with path access¶
Keep bytes-to-bytes or text-to-text rules pure.
Calling whole-file reads streaming¶
A promise-based whole-file result is asynchronous, but it is not a chunk stream.
Check yourself¶
You have completed the tutorial when you can answer these questions:
- Why can
file/readfail whenfile/exists? - What does
file/resolvemake explicit? - Which value type does
file/readproduce through its promise? - Where should UTF-8 decoding occur?
- Why should document transformation remain pure?
- How does a returned write promise extend a promise chain?
- When is a coroutine clearer than a short promise chain?
- Why is whole-file asynchronous I/O not automatically streaming?
Continue with the Hara language guide or the runtime library contract.