stdlib icon indicating copy to clipboard operation
stdlib copied to clipboard

Suggestion: Add `bit_array.to_lossy_string()` or similar

Open JonasHedEng opened this issue 9 months ago • 10 comments
trafficstars

There is currently no way to (easily) convert from a BitArray that contains non-UTF codepoints to a String. This is usable when, for example, you need to handle filepaths and you're not concerned with the exact naming but want a best-effort conversion.

In Rust there's OsStr and Path-derivations for this. One first step could be to implement something like Rust's to_string_lossy

fn to_lossy_string(bytes: BitArray) -> String {
    todo
}

JonasHedEng avatar Jan 28 '25 17:01 JonasHedEng

Does the following do what you're after? If it can't match a UTF-8 code point then it inserts the replacement character and tries again with the next byte.

import gleam/string

pub fn to_string_lossy(bits: BitArray) -> String {
  to_string_lossy_impl(bits, "")
}

fn to_string_lossy_impl(bits: BitArray, acc: String) -> String {
  case bits {
    <<x:utf8_codepoint, rest:bits>> ->
      to_string_lossy_impl(rest, acc <> string.from_utf_codepoints([x]))
    <<_, rest:bits>> -> to_string_lossy_impl(rest, acc <> "�")
    _ -> acc
  }
}

richard-viney avatar Jan 29 '25 11:01 richard-viney

Pretty much, I think. But it should simply drop the unknown char to_string_lossy_impl(rest, acc <> "�") -> to_string_lossy_impl(rest, acc)

Edit: Nevermind.. Yes, it should not just drop the codepoint, a mapping function or the replacement character would be great!

JonasHedEng avatar Jan 29 '25 12:01 JonasHedEng

Sure yes the replacement char could be empty and/or configurable. The Rust function linked above adds the U+FFFD so the proposed Gleam code currently matches that behaviour.

richard-viney avatar Jan 29 '25 12:01 richard-viney

Being able to configure the behaviour when it fails would be useful. Would we want a fixed replacement or would we want a function that offers the non-unicode bit array and you pick a substitution?

lpil avatar Feb 02 '25 17:02 lpil

There are some use cases for having full control of the substitution, so maybe this:

import gleam/string

pub fn to_string_lossy(
  bits: BitArray,
  map_invalid_byte: fn(Int) -> String,
) -> String {
  to_string_lossy_impl(bits, map_invalid_byte, "")
}

fn to_string_lossy_impl(
  bits: BitArray,
  map_invalid_byte: fn(Int) -> String,
  acc: String,
) -> String {
  case bits {
    <<x:utf8_codepoint, rest:bits>> ->
      to_string_lossy_impl(
        rest,
        map_invalid_byte,
        acc <> string.from_utf_codepoints([x]),
      )

    <<x, rest:bits>> ->
      to_string_lossy_impl(rest, map_invalid_byte, acc <> map_invalid_byte(x))

    _ -> acc
  }
}

The above isn't compatible with the JavaScript target, I can rework it to that end once the function signature is stabilised.

richard-viney avatar Feb 02 '25 20:02 richard-viney

What about when it's not a byte-aligned bit array? Would be nice to map the final bits rather than always delete them.

lpil avatar Feb 04 '25 11:02 lpil

How about this that parses any trailing bits at the end as a final codepoint rather than dropping them:


import gleam/bit_array
import gleam/string

pub fn to_string_lossy(
  bits: BitArray,
  map_invalid_byte: fn(Int) -> String,
) -> String {
  to_string_lossy_impl(bits, map_invalid_byte, "")
}

fn to_string_lossy_impl(
  bits: BitArray,
  map_invalid_byte: fn(Int) -> String,
  acc: String,
) -> String {
  case bits {
    <<x:utf8_codepoint, rest:bits>> ->
      to_string_lossy_impl(
        rest,
        map_invalid_byte,
        acc <> string.from_utf_codepoints([x]),
      )

    <<x, rest:bits>> ->
      to_string_lossy_impl(rest, map_invalid_byte, acc <> map_invalid_byte(x))

    _ ->
      case bit_array.bit_size(bits) {
        0 -> acc
        s -> {
          let assert <<x:size(s)>> = bits
          let assert Ok(cp) = string.utf_codepoint(x)

          acc <> string.from_utf_codepoints([cp])
        }
      }
  }
}

richard-viney avatar Feb 04 '25 11:02 richard-viney

It seems incorrect to me to use a different mapping function for those bits. If we are to let the programmer configure it then it should always be up to the programmer how to handle invalid bits rather than only when there's at least 1 byte

lpil avatar Feb 04 '25 11:02 lpil

Ok, this changes the signature of the mapping function to take a BitArray, and any trailing partial byte is also passed to it:

import gleam/string

pub fn to_string_lossy(
  bits: BitArray,
  map_invalid_bits: fn(BitArray) -> String,
) -> String {
  to_string_lossy_impl(bits, map_invalid_bits, "")
}

fn to_string_lossy_impl(
  bits: BitArray,
  map_invalid_bits: fn(BitArray) -> String,
  acc: String,
) -> String {
  case bits {
    <<>> -> acc

    <<x:utf8_codepoint, rest:bits>> ->
      to_string_lossy_impl(
        rest,
        map_invalid_bits,
        acc <> string.from_utf_codepoints([x]),
      )

    <<x, rest:bits>> ->
      to_string_lossy_impl(rest, map_invalid_bits, acc <> map_invalid_bits(x))

    _ -> acc <> map_invalid_bits(bits)
  }
}

richard-viney avatar Feb 04 '25 12:02 richard-viney

That sounds good!

lpil avatar Feb 05 '25 15:02 lpil