epics_libcom_rs/runtime/json_string.rs
1//! The JSON string decoder a `.db` JSON-brace value goes through — C's yajl.
2//!
3//! A `.db` field value has two escape regimes, and they are NOT the same
4//! function:
5//!
6//! * a QUOTED value (`field(VAL,"a\tb")`) — `dbLexRoutines.c:1398` strips the
7//! quotes and runs [`crate::runtime::epics_string::raw_from_escaped`]
8//! (C `dbTranslateEscape` → `epicsStrnRawFromEscaped`);
9//! * a JSON-BRACE value (`field(INP,{const:"a\tb"})`) — `dbLexRoutines.c:1398`
10//! deliberately leaves it alone (the `if` tests for a leading quote), because
11//! the text is handed to `dbParseLink` → `dbJLinkInit` → `dbJLinkParse`, which
12//! feeds it to **yajl**; yajl's lexer flags a string with escapes and
13//! `yajl_parser.c:273-281` decodes it through `yajl_string_decode`
14//! (`yajl_encode.c:136-215`) before the jlif `string` callback ever sees it
15//! (`lnkConst.c:199`).
16//!
17//! The port carried the brace text verbatim all the way into the record, so
18//! every JSON link carrying an escaped string — `{const:"…"}` seeds,
19//! `{pva:{pv:"…"}}` targets — reached the record with its backslashes intact.
20//!
21//! Measured, softIoc 7.0.10 (linux-x86_64), `dbgf` (which re-escapes for
22//! display, so a stored backslash prints DOUBLED):
23//!
24//! ```text
25//! record(stringin,"J1"){field(DTYP,"Soft Channel") field(INP,{const:"a\tb"})}
26//! dbgf J1.VAL -> "a\tb" i.e. stored a, TAB, b
27//! record(stringin,"J2"){field(DTYP,"Soft Channel") field(INP,{const:"x\\ny"})}
28//! dbgf J2.VAL -> "x\\ny" i.e. stored x, BACKSLASH, n, y
29//! ```
30//!
31//! (`caget`/`caget-rs` are NOT oracles here: both print a DBF_STRING through
32//! `epicsStrnEscapedFromRaw` — `tool_lib.c:135`, `cli.rs:834` — so a stored TAB
33//! comes back on screen as `\t` and reads exactly like an untranslated escape.)
34
35/// C `yajl_string_decode` (`yajl_encode.c:136-215`): decode the CONTENT of a
36/// JSON string (no surrounding quotes).
37///
38/// Base's yajl is the JSON5-extended one, so beyond the standard escapes it
39/// takes `\v`, `\0`, `\xHH`, and a backslash-newline line continuation. An
40/// unknown escape yields the escaped character itself (yajl's `default` arm),
41/// which is what makes `\"`, `\'` and `\/` work without their own cases.
42///
43/// KNOWN DEVIATION (R19-68, tracked separately): `\xHH` with `HH >= 0x80` is
44/// ONE byte in C (`utf8Buf[0] = (char) codepoint`) and two UTF-8 bytes here,
45/// because this returns a `String`. That is the same byte-model gap
46/// [`crate::runtime::epics_string`] documents, and it is fixed in one place for
47/// both decoders, not here.
48pub fn decode_json_string(src: &str) -> String {
49 let b = src.as_bytes();
50 let mut out = String::with_capacity(src.len());
51 let mut i = 0;
52
53 while i < b.len() {
54 if b[i] != b'\\' {
55 // Copy the next whole UTF-8 char (the source is a Rust str, so the
56 // byte at `i` starts one).
57 let start = i;
58 i += 1;
59 while i < b.len() && (b[i] & 0xC0) == 0x80 {
60 i += 1;
61 }
62 out.push_str(&src[start..i]);
63 continue;
64 }
65 i += 1;
66 let Some(&c) = b.get(i) else { break };
67 i += 1;
68 match c {
69 b'r' => out.push('\r'),
70 b'n' => out.push('\n'),
71 b'\\' => out.push('\\'),
72 b'f' => out.push('\u{c}'),
73 b'b' => out.push('\u{8}'),
74 b't' => out.push('\t'),
75 b'v' => out.push('\u{b}'),
76 b'0' => out.push('\0'),
77 // A backslash-newline is a line continuation: it contributes
78 // nothing (yajl `case '\n'` / `case '\r'`).
79 b'\n' => {}
80 b'\r' => {
81 if b.get(i) == Some(&b'\n') {
82 i += 1;
83 }
84 }
85 b'u' => {
86 let Some(cp) = hex_to_digit(b, i, 4) else {
87 // yajl's lexer rejects a short \u; the decoder is never
88 // reached. Keep the text rather than inventing a char.
89 out.push('u');
90 continue;
91 };
92 i += 4;
93 // A high surrogate takes its low partner, exactly as yajl does.
94 let ch = if (0xD800..0xDC00).contains(&cp) {
95 match (b.get(i), b.get(i + 1), hex_to_digit(b, i + 2, 4)) {
96 (Some(b'\\'), Some(b'u'), Some(lo)) if (0xDC00..0xE000).contains(&lo) => {
97 i += 6;
98 let scalar = 0x1_0000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
99 char::from_u32(scalar)
100 }
101 _ => None,
102 }
103 } else {
104 char::from_u32(cp)
105 };
106 // yajl emits '?' for an unpaired surrogate.
107 out.push(ch.unwrap_or('?'));
108 }
109 b'x' => {
110 let Some(byte) = hex_to_digit(b, i, 2) else {
111 out.push('x');
112 continue;
113 };
114 i += 2;
115 out.push(byte as u8 as char);
116 }
117 // yajl's `default`: the escaped character itself — `\"`, `\'`, `\/`.
118 other => out.push(other as char),
119 }
120 }
121 out
122}
123
124/// A JSON string TOKEN — quotes included — decoded. `None` when `tok` is not a
125/// quoted string (a number, `true`, an object, …): C's jlif `string` callback
126/// is simply not the one yajl calls for those.
127///
128/// This is the ONE place a `.db` JSON string is turned into text. Every site
129/// that used to strip the quotes by hand went through the escapes untranslated.
130pub fn decode_json_string_token(tok: &str) -> Option<String> {
131 let t = tok.trim();
132 let inner = t
133 .strip_prefix('"')
134 .and_then(|r| r.strip_suffix('"'))
135 .or_else(|| t.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')))?;
136 Some(decode_json_string(inner))
137}
138
139/// `hexToDigit(&cp, n, src)` — `n` hex digits, or `None` if they are not there.
140fn hex_to_digit(b: &[u8], at: usize, n: usize) -> Option<u32> {
141 let mut v: u32 = 0;
142 for k in 0..n {
143 let d = (*b.get(at + k)? as char).to_digit(16)?;
144 v = v << 4 | d;
145 }
146 Some(v)
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 /// The escapes yajl names, one case each.
154 #[test]
155 fn yajl_escapes() {
156 assert_eq!(decode_json_string(r"a\tb"), "a\tb");
157 assert_eq!(decode_json_string(r"a\nb"), "a\nb");
158 assert_eq!(decode_json_string(r"a\rb"), "a\rb");
159 assert_eq!(decode_json_string(r"a\fb"), "a\u{c}b");
160 assert_eq!(decode_json_string(r"a\bb"), "a\u{8}b");
161 assert_eq!(decode_json_string(r"a\vb"), "a\u{b}b");
162 assert_eq!(decode_json_string(r"x\\ny"), "x\\ny");
163 assert_eq!(decode_json_string(r#"a\"b"#), "a\"b");
164 assert_eq!(decode_json_string(r"a\0b"), "a\0b");
165 assert_eq!(decode_json_string(r"a\x41b"), "aAb");
166 assert_eq!(decode_json_string(r"aAb"), "aAb");
167 // An unknown escape is the character itself (yajl's `default`).
168 assert_eq!(decode_json_string(r"a\/b"), "a/b");
169 assert_eq!(decode_json_string(r"a\qb"), "aqb");
170 }
171
172 /// A backslash-newline is a line continuation, not a character.
173 #[test]
174 fn line_continuation_contributes_nothing() {
175 assert_eq!(decode_json_string("a\\\nb"), "ab");
176 assert_eq!(decode_json_string("a\\\r\nb"), "ab");
177 }
178
179 /// A surrogate pair is one scalar; an unpaired high surrogate is yajl's `?`.
180 #[test]
181 fn unicode_escapes_and_surrogate_pairs() {
182 assert_eq!(decode_json_string("\\u0041"), "A");
183 assert_eq!(decode_json_string("\\uD83D\\uDE00"), "\u{1F600}");
184 assert_eq!(decode_json_string("\\uD83Dx"), "?x");
185 }
186
187 /// Text with no escapes is untouched — the common case must not be mangled.
188 #[test]
189 fn plain_text_is_untouched() {
190 assert_eq!(decode_json_string("hi there"), "hi there");
191 assert_eq!(decode_json_string("REC:AI.VAL"), "REC:AI.VAL");
192 }
193
194 /// The token form: only a quoted string is a string.
195 #[test]
196 fn only_a_quoted_token_is_a_string() {
197 assert_eq!(
198 decode_json_string_token(r#""a\tb""#).as_deref(),
199 Some("a\tb")
200 );
201 assert_eq!(decode_json_string_token(r"'a\tb'").as_deref(), Some("a\tb"));
202 assert_eq!(decode_json_string_token("5"), None);
203 assert_eq!(decode_json_string_token("true"), None);
204 assert_eq!(decode_json_string_token("{a:1}"), None);
205 }
206}