Skip to content

Accept &str and implement Display for ergonomic zero-alloc escaping #9

@leighmcculloch

Description

@leighmcculloch

What problem does your feature solve?

Two friction points show up together whenever a downstream crate wants to embed escaped output in a log line or CLI message.

1. No way to format escaped bytes without allocating. The crate exposes escape(bytes) -> Vec<u8> and escape_into(&mut [u8], bytes) -> Result<usize, _>. Neither plugs into format! / write! directly — callers must allocate an intermediate Vec<u8> (and usually convert it to String) even though the result is going straight into another format machine.

2. String inputs require .as_bytes() at every call site. The input bound is IntoIterator<Item: Borrow<u8>>, which &str does not satisfy, so callers must remember the conversion every time.

Example from stellar-cli (stellar/stellar-cli#2509), which is the motivating downstream:

fn sanitize(s: &str) -> String {
    escape_bytes::escape(s.as_bytes())
        .into_iter()
        .map(char::from)
        .collect()
}
print.infoln(format!("Message: {}", sanitize(&message_display)));

Allocation + UTF-8 construction + an .as_bytes() dance, all for output that is immediately consumed by another formatter.

What would you like to see?

1. Display for Escape<I>. Implement core::fmt::Display on the existing Escape<I> iterator so it can be dropped into format! / write! with no intermediate buffer. Because Display::fmt takes &self, the impl needs I::IntoIter: Clone — the same bound the existing impl Clone for Escape<I> already carries, so &[u8], &Vec<u8>, array references, etc. all satisfy it. Works under no_std.

2. Accept &str wherever bytes are accepted today. Let Escape::new, escape, and escape_into take string input directly, without requiring callers to write .as_bytes(). Existing inputs (&[u8], Vec<u8>, [u8; N], &[u8; N], arbitrary IntoIterator<Item: Borrow<u8>>) must continue to compile unchanged.

Together, callers would then write:

use escape_bytes::Escape;
print.infoln(format!("Message: {}", Escape::new(&message_display)));

No .as_bytes(), no intermediate buffer, no helper wrapper, no unsafe.

One implementation shape for the &str piece is a small conversion trait (e.g. IntoEscape) blanket-implemented for IntoIterator<Item: Borrow<u8>> and separately implemented for &str, with the constructors switching to impl IntoEscape. Coherence between the blanket and the &str impl needs verification during implementation; an equivalent alternative is a sealed adapter trait explicitly implemented for each supported input type.

Scope:

  • Add impl core::fmt::Display for Escape<I> in src/escape.rs.
  • Widen the input bound on Escape::new, escape, and escape_into so &str is accepted.
  • Tests covering: empty input, printable-only input, the hex-escape path, the format-interpolation case, and &str input to each entry point.
  • Rustdoc examples using &str directly.

What alternatives are there?

  1. Free function display(bytes: &[u8]) -> impl Display + '_ instead of impl Display for Escape. Works, but adds API surface for something the existing iterator can already carry, and the bare name reads out of place against the rest of the escape_* API.
  2. Separate from_str constructor on Escape instead of widening new. Backward-compatible with zero risk, but still asks the caller to pick the right constructor per input type rather than "just pass the thing."
  3. escape_to_string(bytes: &[u8]) -> String convenience wrapper. Still allocates; only addresses the "I want a String" case, not the "interpolate inline" case that's the hot path downstream.
  4. Leave as-is; downstream crates keep writing a local sanitize helper. What stellar-cli does today. Reasonable as a workaround, but the helper has to live somewhere — in stellar-cli it currently sits in a contract-spec formatter and is called from an unrelated command, a layering smell that goes away once this crate exposes the primitive.

Prior art for the proposed shape: str::escape_default (and friends) in std return iterators that implement Display; bstr::ByteSlice::escape_bytes does the same.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions