xo-umbrella2/xo-ppsink/include/xo/ppsink/escape.hpp

226 lines
8.6 KiB
C++

/** @file escape.hpp
*
* @author Roland Conybeare, Jul 2026
*
* Escape rules for PpSink::put_with_escape(), in one place.
*
* Both PpSink implementations need these rules -- FlatSink expands straight to
* its ostream, PrettySink/PpState expands into a PpStringToken -- but the token
* mechanics differ, so only the *rules* are shared here. Two hand-written
* escape switches would drift.
*
* Escaping only; no surrounding quotes. Whether to quote (never / only when
* ambiguous / always) is a separate policy that belongs above this layer.
* Note in particular that space is NOT escaped: an embedded space is a reason
* to *quote*, which is that upper layer's concern.
*
* This header is ostream-free, like the rest of the ppsink core.
**/
#pragma once
#include <cstdint>
#include <string_view>
namespace xo::pp {
/** @class Escape
* @brief Escape rules for @ref PpSink::put_with_escape.
*
* A class used as a namespace: no instances, all members static.
* Grouped this way so the rules, the size scan, the copy, and the
* expansion bound all sit together and stay consistent.
*
* Use:
* @code
* auto [esc_z, esc_flag] = Escape::str_size(s);
* char * buf = ...; // esc_z bytes
* char * end = Escape::str_copy(s, buf); // end - buf == esc_z
* @endcode
**/
class Escape {
public:
/** @defgroup escape-constants constants **/
///@{
/** longest expansion of a single input character, in bytes.
* Attained by the @c \xNN form (see @ref needs_hex).
*
* A caller expanding incrementally into a fixed buffer must keep this
* many bytes in reserve before handling the next character.
**/
static constexpr std::uint32_t c_max_char_expand = 4;
/** surrounding quote character, when a caller asks to quote.
*
* Lives here, next to @ref pair_char, because the two must agree:
* whatever delimits the string has to be escaped inside it.
**/
static constexpr char c_quote = '"';
/** bytes added by surrounding quotes: one at each end **/
static constexpr std::uint32_t c_quote_expand = 2;
///@}
/** @defgroup escape-types types **/
///@{
/** @brief result of @ref str_size.
*
* Supports structured binding, so a caller that wants both can write
* @code auto [esc_z, esc_flag] = Escape::str_size(s); @endcode
**/
struct size_result {
/** number of bytes @ref str_copy will write **/
std::uint32_t size = 0;
/** true iff printing this string bare would be ambiguous, so the
* caller has to surround it with @ref c_quote. This is the unq()
* predicate. True when any of:
*
* - a character was escaped, since a backslash or a quote in the
* output needs delimiters to be read back;
* - the string contains a space, which bare would read as a
* separator;
* - the string is empty, which bare would be nothing at all.
*
* Note the name is looser than it reads: the last two cases set
* this without anything actually being escaped, so it is a
* "needs quoting" flag rather than a "was modified" flag. It is
* therefore NOT a test for @c str_copy(s) == s.
**/
bool escape_flag = false;
};
///@}
/** @defgroup escape-rules per-character rules **/
///@{
/** @brief second character of @p uc's two-character escape, or 0 if none.
*
* Covers the seven escapes with a short form:
* @code
* \ -> \\ " -> \" newline -> \n cr -> \r
* tab -> \t backspace -> \b formfeed -> \f
* @endcode
*
* Consulted before @ref needs_hex, since all five of newline, cr, tab,
* backspace and formfeed are also control characters and their short
* form is preferred.
*
* This is exactly the set JSON spells with a backslash pair, minus
* @c \/ (which needs no escaping here). The remaining control
* characters still go to @c \xNN, which is NOT valid JSON -- making
* ppsink output valid JSON needs @c \uXXXX and is a separate question,
* see .xo-backlog/xo-printjson/issues/01-ppsink-api.md.
*
* Note the set is wider than what xo-reader accepts back
* (@c \n \r \" \\ only), so an escaped string is not automatically a
* round-trippable Schematika literal -- see
* .xo-backlog/xo-object/issues/01-string-display-schematika.md.
**/
static constexpr char
pair_char(unsigned char uc) noexcept {
switch (uc) {
case '\\': return '\\';
case '"': return '"';
case '\n': return 'n';
case '\r': return 'r';
case '\t': return 't';
case '\b': return 'b';
case '\f': return 'f';
default: return '\0';
}
}
/** @brief true iff @p uc must be escaped as @c \xNN (no short form).
*
* Control characters have no printable representation, and one of them
* -- ESC (0x1b) -- actively corrupts layout: PpState::count_visible_chars
* treats ESC as the start of a (zero-width) color escape, so a raw ESC
* in a string token makes its visible length undercount and breaks the
* pretty-printer's line-fitting. Escaping them makes that unreachable.
*
* Bytes >= 0x80 are deliberately left alone so UTF-8 passes through
* intact; note the unsigned char parameter, since plain char is signed
* on the platforms xo builds for.
**/
static constexpr bool
needs_hex(unsigned char uc) noexcept {
return (uc < 0x20) || (uc == 0x7f);
}
///@}
/** @defgroup escape-methods scan and copy **/
///@{
/** @brief scan @p s: bytes @ref str_copy will write, and whether the
* result needs surrounding quotes to be unambiguous.
*
* Single pass, no allocation. Callers need the size before allocating,
* since PpState places a PpStringToken of known size and then fills it.
* @ref size_result::escape_flag is free to compute in the same pass,
* and is what unq() branches on.
**/
static constexpr size_result
str_size(std::string_view s) noexcept {
size_result retval;
/* bare, an empty string would print as nothing at all */
retval.escape_flag = s.empty();
for (char ch : s) {
auto uc = static_cast<unsigned char>(ch);
if (pair_char(uc)) {
retval.size += 2;
retval.escape_flag = true;
} else if (needs_hex(uc)) {
retval.size += c_max_char_expand; /* \xNN */
retval.escape_flag = true;
} else {
retval.size += 1;
/* not escaped, but bare it would read as a separator */
if (uc == ' ')
retval.escape_flag = true;
}
}
return retval;
}
/** @brief write @p s to @p dest with escapes expanded; return one-past-end.
*
* Requires space for @c str_size(s).size bytes at @p dest. Writes
* exactly that many -- @ref str_size and this method branch on the same
* predicates in the same order, and the tests pin the equality.
**/
static constexpr char *
str_copy(std::string_view s, char * dest) noexcept {
for (char ch : s) {
auto uc = static_cast<unsigned char>(ch);
if (char pair = pair_char(uc)) {
*dest++ = '\\';
*dest++ = pair;
} else if (needs_hex(uc)) {
*dest++ = '\\';
*dest++ = 'x';
*dest++ = c_hex_digit[(uc >> 4) & 0x0f];
*dest++ = c_hex_digit[uc & 0x0f];
} else {
*dest++ = ch;
}
}
return dest;
}
///@}
private:
/** lowercase nibble -> hex digit, for the @c \xNN form **/
static constexpr char c_hex_digit[] = "0123456789abcdef";
};
} /*namespace xo::pp*/
/* end escape.hpp */