/// Escapes a string to be safely used inside an HTML document by escaping /// the following characters: /// - `<` becomes `<` /// - `>` becomes `>` /// - `&` becomes `&` /// - `"` becomes `"` /// - `'` becomes `'`. /// /// ## Examples /// /// ```gleam /// assert escape("wibble & wobble") == "wibble & wobble" /// assert escape("wibble > wobble") == "wibble > wobble" /// ``` /// @external(javascript, "./houdini.ffi.mjs", "escape") pub fn escape(string: String) -> String { // This version is highly optimised for the Erlang target, it treats Strings // as BitArrays and slices them to share as much as possible. You can find // more details in `do_escape`. let bits = <> let result = do_escape(bits, 0, bits, <<>>) // we know that the BitArray we build is definitely a valid string, so we can // skip verifying that again as well as dealing with the `Result`. unsafe_bit_array_to_string(result) } // A possible way to escape chars would be to split the string into graphemes, // traverse those one by one and accumulate them back into a string escaping // ">", "<", etc. as we see them. // However, we can be a lot more performant by working directly on the // `BitArray` used to represent a Gleam UTF-8 String: instead of popping a // grapheme at a time, we can work directly on BitArray slices: this has the big // advantage of making sure we share as much as possible with the original // string without having to build a new one from scratch. fn do_escape( bin: BitArray, skip: Int, original: BitArray, acc: BitArray, ) -> BitArray { case bin { // If we find a char to escape we just advance the `skip` counter so that // it will be ignored in the following slice, then we append the escaped // version to the accumulator. <<"<", rest:bits>> -> { let acc = <> do_escape(rest, skip + 1, original, acc) } <<">", rest:bits>> -> { let acc = <> do_escape(rest, skip + 1, original, acc) } <<"&", rest:bits>> -> { let acc = <> do_escape(rest, skip + 1, original, acc) } <<"\"", rest:bits>> -> { let acc = <> do_escape(rest, skip + 1, original, acc) } <<"'", rest:bits>> -> { let acc = <> do_escape(rest, skip + 1, original, acc) } // For any other byte that doesn't need to be escaped we go into an inner // loop, consuming as much "non-escapable" chars as possible. <<_char, rest:bits>> -> do_escape_normal(rest, skip, original, acc, 1) <<>> -> acc _ -> panic as "do_escape: non byte aligned string, all strings should be byte aligned" } } fn do_escape_normal( bin: BitArray, skip: Int, original: BitArray, acc: BitArray, len: Int, ) -> BitArray { // Remember, if we're here it means we've found a char that doesn't need to be // escaped, so what we want to do is advance the `len` counter until we reach // a char that _does_ need to be escaped and take the slice going from // `skip`, with size `len`. // // Imagine we're escaping this string: "abc> -> { let acc = <> do_escape(rest, skip + len + 1, original, acc) } <<">", rest:bits>> -> { let acc = <> do_escape(rest, skip + len + 1, original, acc) } <<"&", rest:bits>> -> { let acc = <> do_escape(rest, skip + len + 1, original, acc) } <<"\"", rest:bits>> -> { let acc = <> do_escape(rest, skip + len + 1, original, acc) } <<"'", rest:bits>> -> { let acc = <> do_escape(rest, skip + len + 1, original, acc) } // Otherwise we know that the first byte doesn't need any escape. The easy // thing to do would be to just advance by that one byte and keep going over // over the string. As you might notice here we're doing something a bit // more involved: we look at the following 7 bytes, and if none of those // needs escaping, then we skip this whole chunk of bytes entirely. // // This doesn't change the behaviour of the program, but it can make it a // whole load faster to go over the entire string. Especially if there's // fewer characters that need escaping! // // This idea comes from the amazing talk "Engineering json - Achieving Top // Performance on the BEAM" by MichaƂ Muskala at Code BEAM Europe 2024: // https://www.youtube.com/watch?v=Z0swkSXAPBE <<_, b, c, d, e, f, g, h, rest:bits>> if { b != 34 && b != 38 && b != 39 && b != 60 && b != 62 } && { c != 34 && c != 38 && c != 39 && c != 60 && c != 62 } && { d != 34 && d != 38 && d != 39 && d != 60 && d != 62 } && { e != 34 && e != 38 && e != 39 && e != 60 && e != 62 } && { f != 34 && f != 38 && f != 39 && f != 60 && f != 62 } && { g != 34 && g != 38 && g != 39 && g != 60 && g != 62 } && { h != 34 && h != 38 && h != 39 && h != 60 && h != 62 } -> do_escape_normal(rest, skip, original, acc, len + 8) // However, if any of the following bytes needs escaping, we skip over just // the first byte! <<_, rest:bits>> -> do_escape_normal(rest, skip, original, acc, len + 1) <<>> -> // We start from the start of the bit array and have consumed everything // without finding a char that is not valid. This means that the entire // string doesn't need any escaping, we can just return it as is! case skip { 0 -> original _ -> <> } _ -> panic as "do_escape_normal: non byte aligned string, all strings should be byte aligned" } } @external(erlang, "gleam@function", "identity") fn unsafe_bit_array_to_string(_bit_array: BitArray) -> String { panic as "usafe_bit_array_to_string: this shouldn't be needed on the JavaScript target" } @external(erlang, "binary", "part") fn slice(_bit_array: BitArray, _from: Int, _size: Int) -> BitArray { panic as "slice: this shouldn't be needed on the JavaScript target" }