Skip to main content

epics_libcom_rs/runtime/
epics_string.rs

1//! C `libCom` `epicsString.c` — the escape-translation half.
2//!
3//! [`raw_from_escaped`] is `epicsStrnRawFromEscaped` (epicsString.c:49-118),
4//! which is also all that the deprecated `dbTranslateEscape` (`:41-47`) does.
5//! It is the SINGLE owner of `\`-escape translation, because C has exactly one:
6//! every caller that must turn a source-text escape into the byte it denotes
7//! goes through it —
8//!
9//! [`print_escaped`] is the opposite direction — `epicsStrPrintEscaped`
10//! (epicsString.c:230-262), which `asDumpQuoted` (asLibRoutines.c, epics-base
11//! PR #871) escapes UAG/HAG members through before printing them quoted.
12//!
13//! * the `.db` loader, for field and info VALUES (`dbLexRoutines.c:1398-1403`
14//!   and `:1435-1440`, both `dbTranslateEscape(value, value)`), and
15//! * iocsh `echo` (`libComRegister.c:84-91`).
16//!
17//! Record/alias NAMES are deliberately NOT translated: the `.db` lexer reads
18//! them with a different rule (`dbLex.l:88-92`), which strips the quotes and
19//! keeps the escape bytes raw. Do not route a name through here.
20//!
21//! Verified against softIoc 7.0.10.1-DEV (`dbgf` re-escapes on print, so these
22//! are the STORED bytes):
23//!
24//! ```text
25//! field(DESC, "hex\x41end")  -> hexAend
26//! field(VAL,  "d:\q.")       -> d:q.        (unknown escape: the char itself)
27//! field(VAL,  "u:A.")   -> u:u0041.    (C has no \u here — the lexer
28//!                                            accepts it, the translation does
29//!                                            not implement it)
30//! ```
31
32/// Translate C escape sequences to the bytes they denote — C
33/// `epicsStrnRawFromEscaped` (epicsString.c:49-118).
34///
35/// * `\a \b \f \n \r \t \v \\ \' \"` — the control/literal character.
36/// * `\0` — NUL. This is the literal digit zero, not the start of an octal
37///   escape: C has no octal escape here, and its `.db` lexer rejects `\1`..`\9`
38///   outright (`escapedchar` is `{backslash}[^ux1-9]`, dbLex.l:25).
39/// * `\xH` / `\xHH` — one or two hex digits, the byte they spell. A `\x` NOT
40///   followed by a hex digit emits nothing and the offending character is
41///   re-examined as ordinary input — so `\x\n` is a newline, exactly as C's
42///   `goto input` does.
43/// * Any other escaped character — the character itself (`\q` is `q`).
44/// * A trailing lone `\` is dropped.
45///
46/// A `\xHH` denotes ONE byte — including `HH >= 0x80`, where C's `OUT(u)`
47/// (`epicsString.c:105`) writes a single `char`. That is why this returns
48/// BYTES: a `DBF_STRING` is a byte string with a 40-byte budget, and modelling
49/// it as a Rust `String` turned `\xff` into the two UTF-8 bytes of U+00FF.
50/// Measured on softIoc — `field(VAL,"h\xffz")` stores the three bytes `h`,
51/// `0xFF`, `z` (`dbgf` prints `"h\xffz"`, ONE escape; a two-byte UTF-8
52/// encoding would print `"h\xc3\xbfz"`).
53pub fn raw_from_escaped(src: &str) -> Vec<u8> {
54    raw_from_escaped_bytes(src.as_bytes())
55}
56
57/// [`raw_from_escaped`] on bytes — C takes a `char *`, and so does this.
58pub fn raw_from_escaped_bytes(src: &[u8]) -> Vec<u8> {
59    let mut out = Vec::with_capacity(src.len());
60    let mut i = 0;
61
62    while i < src.len() {
63        let c = src[i];
64        i += 1;
65
66        if c != b'\\' {
67            out.push(c);
68            continue;
69        }
70
71        // A lone trailing backslash: C breaks out of the loop, emitting nothing.
72        let Some(&esc) = src.get(i) else { break };
73        i += 1;
74
75        match esc {
76            b'a' => out.push(0x07),
77            b'b' => out.push(0x08),
78            b'f' => out.push(0x0c),
79            b'n' => out.push(b'\n'),
80            b'r' => out.push(b'\r'),
81            b't' => out.push(b'\t'),
82            b'v' => out.push(0x0b),
83            b'\\' => out.push(b'\\'),
84            b'\'' => out.push(b'\''),
85            b'"' => out.push(b'"'),
86            b'0' => out.push(0),
87            b'x' => {
88                let mut value: u32 = 0;
89                let mut digits = 0;
90                while digits < 2 {
91                    match src.get(i).and_then(|c| (*c as char).to_digit(16)) {
92                        Some(d) => {
93                            value = value << 4 | d;
94                            digits += 1;
95                            i += 1;
96                        }
97                        None => break,
98                    }
99                }
100                if digits == 0 {
101                    // C `goto input`: the `\x` yields nothing and the character
102                    // that followed it is re-read as ordinary input — which is
103                    // what the outer loop does next, since `i` was not advanced
104                    // past it.
105                    continue;
106                }
107                // C `OUT(u)`: ONE byte, whatever the two hex digits spell.
108                out.push(value as u8);
109            }
110            other => out.push(other),
111        }
112    }
113
114    out
115}
116
117/// Escape bytes for display — C `epicsStrPrintEscaped` (epicsString.c:230-262),
118/// the `FILE *` form, returning the text instead of writing a stream.
119///
120/// * `\a \b \f \n \r \t \v \\ \' \"` — the named escapes.
121/// * Printable ASCII (C-locale `isprint`) — the byte itself.
122/// * Anything else — `\xNN`, lower-case hex.
123/// * NUL — `\0`. C's `switch` here forgot the NUL case (so C prints `\x00`)
124///   while its sibling `epicsStrnEscapedFromRaw` has a deliberate `case '\0'`
125///   (`:145`); the port refuses that divergence (CBUG-D4, same refusal as
126///   `asyn-rs`'s private copy of this table) and renders `\0` — the form
127///   [`raw_from_escaped`]'s `case '0'` decodes back to NUL.
128/// * C guards on `strlen(s) == 0` (`:236-237`) despite taking an explicit
129///   length: a buffer whose FIRST byte is NUL prints nothing at all, however
130///   many bytes follow it. Kept.
131pub fn print_escaped(src: &[u8]) -> String {
132    if src.first().is_none_or(|&c| c == 0) {
133        return String::new();
134    }
135    let mut out = String::with_capacity(src.len());
136    for &c in src {
137        match c {
138            0x07 => out.push_str("\\a"),
139            0x08 => out.push_str("\\b"),
140            0x0c => out.push_str("\\f"),
141            b'\n' => out.push_str("\\n"),
142            b'\r' => out.push_str("\\r"),
143            b'\t' => out.push_str("\\t"),
144            0x0b => out.push_str("\\v"),
145            b'\\' => out.push_str("\\\\"),
146            b'\'' => out.push_str("\\'"),
147            b'"' => out.push_str("\\\""),
148            0 => out.push_str("\\0"),
149            0x20..=0x7e => out.push(c as char),
150            _ => {
151                use std::fmt::Write;
152                let _ = write!(out, "\\x{c:02x}");
153            }
154        }
155    }
156    out
157}
158
159#[cfg(test)]
160mod tests {
161    use super::print_escaped;
162    use super::raw_from_escaped;
163
164    /// The softIoc transcripts quoted in the module docs.
165    #[test]
166    fn oracle_cases() {
167        assert_eq!(raw_from_escaped("hex\\x41end"), b"hexAend");
168        assert_eq!(raw_from_escaped("d:\\q."), b"d:q.");
169        assert_eq!(raw_from_escaped("u:\\u0041."), b"u:u0041.");
170        assert_eq!(raw_from_escaped("b:\\x4a."), b"b:J.");
171        assert_eq!(raw_from_escaped("a \\\"b\\\" c"), b"a \"b\" c");
172        assert_eq!(raw_from_escaped("x\\ty"), b"x\ty");
173        assert_eq!(raw_from_escaped("sq:\\tx"), b"sq:\tx");
174    }
175
176    #[test]
177    fn control_escapes() {
178        assert_eq!(
179            raw_from_escaped("\\a\\b\\f\\n\\r\\t\\v\\\\\\'"),
180            b"\x07\x08\x0c\n\r\t\x0b\\'"
181        );
182        assert_eq!(raw_from_escaped("a\\0b"), b"a\0b");
183    }
184
185    /// A single hex digit is enough for the translation (the `.db` lexer
186    /// demands two, but `echo` reaches the same function with no lexer).
187    #[test]
188    fn hex_escape_takes_one_or_two_digits() {
189        assert_eq!(raw_from_escaped("\\x41"), b"A");
190        assert_eq!(raw_from_escaped("\\x7"), b"\x07");
191        assert_eq!(raw_from_escaped("\\x41x"), b"Ax");
192    }
193
194    /// R19-68: a `\xHH` at or above 0x80 is ONE byte, as C's `OUT(u)` writes
195    /// one `char`. Modelled as a Rust `String` it was the TWO UTF-8 bytes of
196    /// the Latin-1 code point, and every DBF_STRING carrying one was wrong on
197    /// the wire and against the 40-byte budget.
198    ///
199    /// softIoc: `record(stringin,"X1"){field(VAL,"h\xffz")}` -> `dbgf X1.VAL`
200    /// prints `"h\xffz"` — ONE escape. A two-byte UTF-8 encoding of U+00FF
201    /// would print `"h\xc3\xbfz"`.
202    #[test]
203    fn a_high_hex_escape_is_one_byte() {
204        assert_eq!(raw_from_escaped("h\\xffz"), vec![b'h', 0xFF, b'z']);
205        assert_eq!(raw_from_escaped("\\x80"), vec![0x80]);
206        assert_eq!(raw_from_escaped("\\xc3\\xa9"), vec![0xC3, 0xA9]);
207    }
208
209    /// `\x` with no hex digit at all: the `\x` disappears and the next
210    /// character is re-read as input — C's `goto input`, so an escape starting
211    /// there is still honoured.
212    #[test]
213    fn hex_escape_without_digits_reexamines_the_next_char() {
214        assert_eq!(raw_from_escaped("\\xzz"), b"zz");
215        assert_eq!(raw_from_escaped("a\\x\\tb"), b"a\tb");
216    }
217
218    #[test]
219    fn trailing_backslash_is_dropped() {
220        assert_eq!(raw_from_escaped("abc\\"), b"abc");
221    }
222
223    #[test]
224    fn plain_text_is_untouched() {
225        assert_eq!(raw_from_escaped("@asyn(PORT,0)"), b"@asyn(PORT,0)");
226    }
227
228    /// `epicsStrPrintEscaped`'s table: named escapes, isprint passthrough,
229    /// lower-case `\xNN` for the rest, and NUL as `\0` (CBUG-D4 refused — C
230    /// prints `\x00` from this function but `\0` from
231    /// `epicsStrnEscapedFromRaw`; the port renders the decodable form from
232    /// both). Round-trips through [`raw_from_escaped`].
233    #[test]
234    fn print_escaped_renders_the_c_table() {
235        assert_eq!(
236            print_escaped(b"\x07\x08\x0c\n\r\t\x0b\\'\""),
237            r#"\a\b\f\n\r\t\v\\\'\""#
238        );
239        assert_eq!(print_escaped(b" ~OK"), " ~OK");
240        assert_eq!(print_escaped(b"\x03\x1b\x7f\xff"), r"\x03\x1b\x7f\xff");
241        assert_eq!(print_escaped(b"a\0b"), r"a\0b");
242
243        let raw = b"a\"b\\c\td";
244        assert_eq!(raw_from_escaped(&print_escaped(raw)), raw);
245    }
246
247    /// R17-49: C guards on `strlen(s) == 0` (epicsString.c:236-237), so a
248    /// buffer whose first byte is NUL prints nothing — only the FIRST byte;
249    /// a later NUL escapes normally.
250    #[test]
251    fn print_escaped_prints_nothing_when_the_first_byte_is_nul() {
252        assert_eq!(print_escaped(b"\0a"), "");
253        assert_eq!(print_escaped(b""), "");
254        assert_eq!(print_escaped(b"x\0y"), r"x\0y");
255    }
256}