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//! * the `.db` loader, for field and info VALUES (`dbLexRoutines.c:1398-1403`
10//!   and `:1435-1440`, both `dbTranslateEscape(value, value)`), and
11//! * iocsh `echo` (`libComRegister.c:84-91`).
12//!
13//! Record/alias NAMES are deliberately NOT translated: the `.db` lexer reads
14//! them with a different rule (`dbLex.l:88-92`), which strips the quotes and
15//! keeps the escape bytes raw. Do not route a name through here.
16//!
17//! Verified against softIoc 7.0.10.1-DEV (`dbgf` re-escapes on print, so these
18//! are the STORED bytes):
19//!
20//! ```text
21//! field(DESC, "hex\x41end")  -> hexAend
22//! field(VAL,  "d:\q.")       -> d:q.        (unknown escape: the char itself)
23//! field(VAL,  "u:A.")   -> u:u0041.    (C has no \u here — the lexer
24//!                                            accepts it, the translation does
25//!                                            not implement it)
26//! ```
27
28/// Translate C escape sequences to the bytes they denote — C
29/// `epicsStrnRawFromEscaped` (epicsString.c:49-118).
30///
31/// * `\a \b \f \n \r \t \v \\ \' \"` — the control/literal character.
32/// * `\0` — NUL. This is the literal digit zero, not the start of an octal
33///   escape: C has no octal escape here, and its `.db` lexer rejects `\1`..`\9`
34///   outright (`escapedchar` is `{backslash}[^ux1-9]`, dbLex.l:25).
35/// * `\xH` / `\xHH` — one or two hex digits, the byte they spell. A `\x` NOT
36///   followed by a hex digit emits nothing and the offending character is
37///   re-examined as ordinary input — so `\x\n` is a newline, exactly as C's
38///   `goto input` does.
39/// * Any other escaped character — the character itself (`\q` is `q`).
40/// * A trailing lone `\` is dropped.
41///
42/// A `\xHH` denotes ONE byte — including `HH >= 0x80`, where C's `OUT(u)`
43/// (`epicsString.c:106`) writes a single `char`. That is why this returns
44/// BYTES: a `DBF_STRING` is a byte string with a 40-byte budget, and modelling
45/// it as a Rust `String` turned `\xff` into the two UTF-8 bytes of U+00FF.
46/// Measured on softIoc — `field(VAL,"h\xffz")` stores the three bytes `h`,
47/// `0xFF`, `z` (`dbgf` prints `"h\xffz"`, ONE escape; a two-byte UTF-8
48/// encoding would print `"h\xc3\xbfz"`).
49pub fn raw_from_escaped(src: &str) -> Vec<u8> {
50    raw_from_escaped_bytes(src.as_bytes())
51}
52
53/// [`raw_from_escaped`] on bytes — C takes a `char *`, and so does this.
54pub fn raw_from_escaped_bytes(src: &[u8]) -> Vec<u8> {
55    let mut out = Vec::with_capacity(src.len());
56    let mut i = 0;
57
58    while i < src.len() {
59        let c = src[i];
60        i += 1;
61
62        if c != b'\\' {
63            out.push(c);
64            continue;
65        }
66
67        // A lone trailing backslash: C breaks out of the loop, emitting nothing.
68        let Some(&esc) = src.get(i) else { break };
69        i += 1;
70
71        match esc {
72            b'a' => out.push(0x07),
73            b'b' => out.push(0x08),
74            b'f' => out.push(0x0c),
75            b'n' => out.push(b'\n'),
76            b'r' => out.push(b'\r'),
77            b't' => out.push(b'\t'),
78            b'v' => out.push(0x0b),
79            b'\\' => out.push(b'\\'),
80            b'\'' => out.push(b'\''),
81            b'"' => out.push(b'"'),
82            b'0' => out.push(0),
83            b'x' => {
84                let mut value: u32 = 0;
85                let mut digits = 0;
86                while digits < 2 {
87                    match src.get(i).and_then(|c| (*c as char).to_digit(16)) {
88                        Some(d) => {
89                            value = value << 4 | d;
90                            digits += 1;
91                            i += 1;
92                        }
93                        None => break,
94                    }
95                }
96                if digits == 0 {
97                    // C `goto input`: the `\x` yields nothing and the character
98                    // that followed it is re-read as ordinary input — which is
99                    // what the outer loop does next, since `i` was not advanced
100                    // past it.
101                    continue;
102                }
103                // C `OUT(u)`: ONE byte, whatever the two hex digits spell.
104                out.push(value as u8);
105            }
106            other => out.push(other),
107        }
108    }
109
110    out
111}
112
113#[cfg(test)]
114mod tests {
115    use super::raw_from_escaped;
116
117    /// The softIoc transcripts quoted in the module docs.
118    #[test]
119    fn oracle_cases() {
120        assert_eq!(raw_from_escaped("hex\\x41end"), b"hexAend");
121        assert_eq!(raw_from_escaped("d:\\q."), b"d:q.");
122        assert_eq!(raw_from_escaped("u:\\u0041."), b"u:u0041.");
123        assert_eq!(raw_from_escaped("b:\\x4a."), b"b:J.");
124        assert_eq!(raw_from_escaped("a \\\"b\\\" c"), b"a \"b\" c");
125        assert_eq!(raw_from_escaped("x\\ty"), b"x\ty");
126        assert_eq!(raw_from_escaped("sq:\\tx"), b"sq:\tx");
127    }
128
129    #[test]
130    fn control_escapes() {
131        assert_eq!(
132            raw_from_escaped("\\a\\b\\f\\n\\r\\t\\v\\\\\\'"),
133            b"\x07\x08\x0c\n\r\t\x0b\\'"
134        );
135        assert_eq!(raw_from_escaped("a\\0b"), b"a\0b");
136    }
137
138    /// A single hex digit is enough for the translation (the `.db` lexer
139    /// demands two, but `echo` reaches the same function with no lexer).
140    #[test]
141    fn hex_escape_takes_one_or_two_digits() {
142        assert_eq!(raw_from_escaped("\\x41"), b"A");
143        assert_eq!(raw_from_escaped("\\x7"), b"\x07");
144        assert_eq!(raw_from_escaped("\\x41x"), b"Ax");
145    }
146
147    /// R19-68: a `\xHH` at or above 0x80 is ONE byte, as C's `OUT(u)` writes
148    /// one `char`. Modelled as a Rust `String` it was the TWO UTF-8 bytes of
149    /// the Latin-1 code point, and every DBF_STRING carrying one was wrong on
150    /// the wire and against the 40-byte budget.
151    ///
152    /// softIoc: `record(stringin,"X1"){field(VAL,"h\xffz")}` -> `dbgf X1.VAL`
153    /// prints `"h\xffz"` — ONE escape. A two-byte UTF-8 encoding of U+00FF
154    /// would print `"h\xc3\xbfz"`.
155    #[test]
156    fn a_high_hex_escape_is_one_byte() {
157        assert_eq!(raw_from_escaped("h\\xffz"), vec![b'h', 0xFF, b'z']);
158        assert_eq!(raw_from_escaped("\\x80"), vec![0x80]);
159        assert_eq!(raw_from_escaped("\\xc3\\xa9"), vec![0xC3, 0xA9]);
160    }
161
162    /// `\x` with no hex digit at all: the `\x` disappears and the next
163    /// character is re-read as input — C's `goto input`, so an escape starting
164    /// there is still honoured.
165    #[test]
166    fn hex_escape_without_digits_reexamines_the_next_char() {
167        assert_eq!(raw_from_escaped("\\xzz"), b"zz");
168        assert_eq!(raw_from_escaped("a\\x\\tb"), b"a\tb");
169    }
170
171    #[test]
172    fn trailing_backslash_is_dropped() {
173        assert_eq!(raw_from_escaped("abc\\"), b"abc");
174    }
175
176    #[test]
177    fn plain_text_is_untouched() {
178        assert_eq!(raw_from_escaped("@asyn(PORT,0)"), b"@asyn(PORT,0)");
179    }
180}