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}