Skip to main content

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}