// Native file reading for the CLI.
//
// The CLI is native-only: it needs argv and the filesystem, and the pure
// `gguf`/`tensor` packages deliberately carry no I/O. This file is gated to
// `native` in moon.pkg so wasm-gc builds do not see the `extern "C"` calls.
//
// `fopen`/`fread` are used directly rather than `moonbitlang/async` because the
// CLI reads one file once, up front: an async runtime is a lot of machinery for
// a synchronous single read, and it would add a dependency to a milestone whose
// premise is "read the model with nothing but MoonBit and libc".
///|
/// Clojure to libc's `FILE *`. `#external` keeps the C pointer opaque so the
/// MoonBit side never dereferences it.
#external
pub type MlFile
///|
#borrow(path, mode)
extern "C" fn ml_fopen(path : Bytes, mode : Bytes) -> MlFile = "ml_fopen"
///|
extern "C" fn ml_fclose(f : MlFile) -> Int = "ml_fclose"
///|
extern "C" fn ml_size(f : MlFile) -> Int64 = "ml_size"
///|
extern "C" fn ml_is_null(f : MlFile) -> Int = "ml_is_null"
///|
/// Encode a MoonBit string as a NUL-terminated UTF-8 byte buffer.
///
/// `String` values do not carry a terminator, and `String::to_bytes` is a
/// deprecated, non-UTF-8 representation, so the C string is built explicitly.
fn cstr(s : String) -> Bytes {
@utf8.encode(s) + b"\x00"
}
///|
/// Read exactly `n` bytes from `f` into a freshly allocated `Bytes`.
///
/// The allocation happens on the C side with `moonbit_make_bytes_raw`, so the
/// file exists in the heap exactly once. Staging it through a
/// `FixedArray[Byte]` and then copying into `Bytes` would hold two full copies
/// of a 138 MB model at once, which is the dominant term in this CLI's peak
/// memory.
///
/// Returns a zero-length `Bytes` on a short read; the caller compares against
/// the size it already obtained, so the failure is still reported precisely.
#borrow(handle)
extern "C" fn ml_read_owned(handle : MlFile, n : Int64) -> Bytes = "ml_read_owned"
///|
/// Read an entire file into memory.
///
/// Returns `Err` with a message rather than panicking when the file is missing
/// or unreadable, so a mistyped path produces a diagnostic and a non-zero exit
/// status instead of a traceback.
///
/// Peak allocation is one copy of the file: the size is checked first (which
/// costs no memory), and the bytes are then allocated once on the C side.
pub fn read_file(path : String) -> Result[Bytes, String] {
let f = ml_fopen(cstr(path), cstr("rb"))
if ml_is_null(f) != 0 {
return Err("cannot open file: \{path}")
}
let size = ml_size(f)
if size < 0L {
ml_fclose(f) |> ignore
return Err("cannot determine the size of: \{path}")
}
if size > 0x7FFFFFFFL {
ml_fclose(f) |> ignore
return Err("file is too large to map into a 32-bit buffer: \{size} bytes")
}
let buf = ml_read_owned(f, size)
ml_fclose(f) |> ignore
if buf.length().to_int64() != size {
return Err(
"short read on \{path}: got \{buf.length()} of \{size} bytes",
)
}
Ok(buf)
}
///|
extern "C" fn ml_exit(code : Int) -> Unit = "ml_exit"
///|
/// Terminate the process with `code`.
///
/// Declared here rather than used directly because `exit` is a native-only
/// libc symbol; routing it through this file keeps the non-native entry point
/// free of platform assumptions.
pub fn exit_with(code : Int) -> Unit {
ml_exit(code)
}
///|
#borrow(buf, n)
extern "C" fn ml_stdin_read(buf : FixedArray[Byte], n : Int) -> Int = "ml_stdin_read"
///|
/// Read one line from stdin, without the trailing newline.
///
/// Returns `None` at end of input, which is how interactive chat knows to stop.
/// A fixed-size buffer is used because lines in a chat session are short; a
/// longer line is returned truncated rather than dropped.
pub fn read_line() -> String? {
let buf : FixedArray[Byte] = FixedArray::make(4096, b'\x00')
let n = ml_stdin_read(buf, buf.length())
if n <= 0 {
return None
}
let raw = Bytes::from_array(buf[:n])
let text = @utf8.decode_lossy(raw.view())
// Trim the line terminator the C side left in place.
let mut end = text.length()
while end > 0 {
let ch = text[end - 1]
if ch == '\n' || ch == '\r' {
end = end - 1
} else {
break
}
}
Some(text[:end].to_owned())
}
///|
#borrow(s)
extern "C" fn ml_puts(s : Bytes) -> Int = "ml_puts"
///|
/// Write `s` to stdout without a trailing newline, then flush.
///
/// Core only offers `println`, which is line-buffered; streaming a token at a
/// time needs the bytes to reach the terminal immediately, so this goes through
/// libc and flushes explicitly.
pub fn print_raw(s : String) -> Unit {
ml_puts(@utf8.encode(s) + b"\x00") |> ignore
}