From 5c300de632ace8c66938d2283451a3809df65ff1 Mon Sep 17 00:00:00 2001 From: Anton-4 <17049058+Anton-4@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:15:26 +0200 Subject: [PATCH] Add OsStr.to_bytes Stdout.write_bytes! and Stderr.write_bytes! take bytes, but OsStr only offered the lossy `display` conversion, so an argument that is not valid UTF-8 could not be reported without replacing its raw data with U+FFFD. `to_bytes` returns UTF-8 text and Unix OS strings as their exact bytes; Windows UTF-16 code units are encoded as UTF-8 with unpaired surrogates replaced by U+FFFD, since they have no byte representation. The command-line-args example now reports an argument that is not valid UTF-8 this way. Closes #483 Co-Authored-By: Claude Opus 5 (1M context) --- examples/command-line-args.roc | 15 +++++++++++++++ platform/OsStr.roc | 22 ++++++++++++++++++++++ scripts/test_spec.json | 6 +++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/examples/command-line-args.roc b/examples/command-line-args.roc index 02fd7d93..a9a542e8 100644 --- a/examples/command-line-args.roc +++ b/examples/command-line-args.roc @@ -3,6 +3,7 @@ app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/downl import pf.OsStr import pf.Stdout +import pf.Stderr main! : List(OsStr) => Try({}, _) main! = |args| { @@ -30,6 +31,20 @@ main! = |args| { } } + # Arguments are not guaranteed to be valid UTF-8. `Stderr.write_bytes!` + # takes bytes, and `OsStr.to_bytes` provides them without the U+FFFD + # replacements that `OsStr.display` would introduce. + match OsStr.to_str_try(first_arg) { + Ok(text) => { + Stdout.line!("argument 1 is valid UTF-8: ${text}")? + } + Err(InvalidStr(index)) => { + Stderr.write!("Invalid UTF-8 at byte ${U64.to_str(index)} in argument 1: \"")? + Stderr.write_bytes!(OsStr.to_bytes(first_arg))? + Stderr.line!("\"")? + } + } + Ok({}) } [] => Err(MissingArgument) diff --git a/platform/OsStr.roc b/platform/OsStr.roc index 146172e6..e5b3f826 100644 --- a/platform/OsStr.roc +++ b/platform/OsStr.roc @@ -59,6 +59,21 @@ OsStr := [ WindowsU16s(u16s) => utf16_to_str(u16s) } + ## Convert an OS string to the bytes that represent it, for writing to a byte + ## stream such as [Stdout.write_bytes!] or [Stderr.write_bytes!]. + ## + ## UTF-8 text and Unix OS strings return their exact bytes, so non-Unicode Unix + ## data is preserved. Windows OS strings are UTF-16 code units rather than bytes, + ## so they are encoded as UTF-8 with unpaired surrogates replaced by U+FFFD; + ## that conversion is lossy and must not be used for roundtripping. + to_bytes : OsStr -> List(U8) + to_bytes = |os_str| + match to_raw(os_str) { + Utf8(str) => Str.to_utf8(str) + UnixBytes(bytes) => bytes + WindowsU16s(u16s) => utf16_to_utf8_lossy(u16s) + } + ## Convert an OS string to a best-effort display string, replacing invalid text ## with U+FFFD. This representation is lossy and must not be used for roundtripping. display : OsStr -> Str @@ -245,3 +260,10 @@ expect { ## Equality and hashing preserve representation identity. expect OsStr.utf8("abc") != OsStr.unix("abc") expect Dict.single(OsStr.unix_bytes([97, 255]), "found").get(OsStr.unix_bytes([97, 255])) == Ok("found") + +## `to_bytes` keeps raw Unix bytes and encodes other representations as UTF-8. +expect OsStr.to_bytes(OsStr.utf8("abc")) == [97, 98, 99] +expect OsStr.to_bytes(OsStr.unix("abc")) == [97, 98, 99] +expect OsStr.to_bytes(OsStr.unix_bytes([97, 255, 98])) == [97, 255, 98] +expect OsStr.to_bytes(OsStr.windows("abc")) == [97, 98, 99] +expect OsStr.to_bytes(OsStr.windows_u16s([0xD800, 97])) == [0xEF, 0xBF, 0xBD, 97] diff --git a/scripts/test_spec.json b/scripts/test_spec.json index 972e4d5b..7fb30b94 100644 --- a/scripts/test_spec.json +++ b/scripts/test_spec.json @@ -38,7 +38,11 @@ { "name": "one-argument", "args": ["foo"], - "contains": ["received argument: foo", "back to OsStr: OsStr."], + "contains": [ + "received argument: foo", + "back to OsStr: OsStr.", + "argument 1 is valid UTF-8: foo" + ], "regex": [ "(?:Unix argument, bytes|Windows argument, UTF-16 code units): \\[102, 111, 111\\]" ]