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}