// File-system access for the IO readers and writers: the single place that
// translates `@fs`'s `IOError` into the library's `DataError::IoError`, so the
// `read_*` / `write_*` entry points don't each repeat the catch.
//
// One-file-one-test exemption: this shared plumbing has no dedicated
// test file of its own. (doc-guard: unresolved) It is covered through the
// file-backed `read_csv` / `write_csv` / `write_json` / `write_ndjson`
// tests in the per-format suites and the surrogate-refusal corpus.
///|
/// Read a whole file as a string, mapping a file-system `IOError` to
/// `DataError::IoError` and a byte sequence that is not UTF-8 to
/// `DataError::ParseError`. Shared by every `read_*` entry point. The
/// decode is strict on purpose: a lenient decoder merges a stray byte with
/// the ones after it — swallowing a delimiter or a quote and shifting
/// every cell boundary that follows — or silently drops the rest of the
/// file, so a non-UTF-8 file would read as confidently wrong data.
/// Refusing up front matches `validate_csv_delimiter`'s philosophy: reject
/// a read that cannot round-trip rather than corrupt the data silently.
fn read_text(path : String) -> String raise @types.DataError {
let bytes = @fs.read_file_to_bytes(path) catch {
@fs.IOError(msg) => raise @types.DataError::IoError(msg)
}
@utf8.decode(bytes) catch {
// The error carries the bytes from the first malformed one, so that
// byte's offset is the difference in length.
@utf8.Malformed(tail) =>
raise @types.DataError::ParseError(
@types.ParseErrorDetail::Message(
"file \{path} is not valid UTF-8: first malformed byte at offset \{bytes.length() - tail.length()}",
),
)
}
}
///|
/// Return the code-unit index of the first unpaired UTF-16 surrogate in
/// `content`, or `None` when the string is well-formed. A high surrogate
/// (`0xD800`–`0xDBFF`) must be immediately followed by a low surrogate
/// (`0xDC00`–`0xDFFF`); a low surrogate anywhere else is unpaired.
fn find_unpaired_surrogate(content : String) -> Int? {
let units = content.code_units()
let n = units.length()
let mut i = 0
while i < n {
let cu = units[i]
if cu.is_leading_surrogate() {
if i + 1 < n && units[i + 1].is_trailing_surrogate() {
i += 2
continue
}
return Some(i)
}
if cu.is_trailing_surrogate() {
return Some(i)
}
i += 1
}
None
}
///|
/// Write a string to a file, mapping a file-system `IOError` to
/// `DataError::IoError`. Shared by every `write_*` entry point — io's own
/// and the chart package's `write_vega_lite`, whose cross-package reach is
/// why this is an `#internal(engine)` seam (hidden from the interface,
/// flagged for downstream callers) rather than package-private: the
/// unpaired-surrogate refusal below is a guard, and a guard that exists in
/// one serialiser but not another is a hole, so every file writer routes
/// through this one copy of it.
///
/// Rejects content holding an unpaired UTF-16 surrogate (`raise
/// InvalidOperation`) before encoding: MoonBit strings can legally hold one
/// (e.g. assembled from a raw code unit with `Int::unsafe_to_char` — since
/// moonc 0.10.9 neither string literals nor the JSON parser accept one), but
/// the UTF-8 file encoder
/// merges a lone high surrogate with the *following* code unit — swallowing
/// a CSV delimiter or a JSON closing quote and silently shifting every cell
/// boundary after it. Refusing up front matches `validate_csv_delimiter`'s
/// philosophy: reject a write that cannot round-trip rather than corrupt
/// the file silently.
#doc(hidden)
#internal(engine, "MoonFrame chart export shares the guarded file writer")
pub fn write_text(
path : String,
content : String,
) -> Unit raise @types.DataError {
match find_unpaired_surrogate(content) {
Some(i) =>
raise @types.DataError::InvalidOperation(
"cannot write a string containing an unpaired UTF-16 surrogate " +
"(0x\{content.code_units()[i].to_int().to_string(radix=16)} at code unit \{i}): " +
"the UTF-8 encoding would corrupt the file",
)
None => ()
}
@fs.write_string_to_file(path, content) catch {
@fs.IOError(msg) => raise @types.DataError::IoError(msg)
}
}