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.
130///
131/// The token is DOUBLE-quoted, always. The dialect's single-quoted spelling
132/// (`yajl_lex.c:695-699`) is rewritten by `epics_base_rs::json5`, which owns
133/// every dialect form; accepting it a second time here would put two owners
134/// back on the same grammar.
135pub fn decode_json_string_token(tok: &str) -> Option<String> {
136    let t = tok.trim();
137    let inner = t.strip_prefix('"').and_then(|r| r.strip_suffix('"'))?;
138    Some(decode_json_string(inner))
139}
140
141/// `hexToDigit(&cp, n, src)` — `n` hex digits, or `None` if they are not there.
142fn hex_to_digit(b: &[u8], at: usize, n: usize) -> Option<u32> {
143    let mut v: u32 = 0;
144    for k in 0..n {
145        let d = (*b.get(at + k)? as char).to_digit(16)?;
146        v = v << 4 | d;
147    }
148    Some(v)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    /// The escapes yajl names, one case each.
156    #[test]
157    fn yajl_escapes() {
158        assert_eq!(decode_json_string(r"a\tb"), "a\tb");
159        assert_eq!(decode_json_string(r"a\nb"), "a\nb");
160        assert_eq!(decode_json_string(r"a\rb"), "a\rb");
161        assert_eq!(decode_json_string(r"a\fb"), "a\u{c}b");
162        assert_eq!(decode_json_string(r"a\bb"), "a\u{8}b");
163        assert_eq!(decode_json_string(r"a\vb"), "a\u{b}b");
164        assert_eq!(decode_json_string(r"x\\ny"), "x\\ny");
165        assert_eq!(decode_json_string(r#"a\"b"#), "a\"b");
166        assert_eq!(decode_json_string(r"a\0b"), "a\0b");
167        assert_eq!(decode_json_string(r"a\x41b"), "aAb");
168        assert_eq!(decode_json_string(r"aAb"), "aAb");
169        // An unknown escape is the character itself (yajl's `default`).
170        assert_eq!(decode_json_string(r"a\/b"), "a/b");
171        assert_eq!(decode_json_string(r"a\qb"), "aqb");
172    }
173
174    /// A backslash-newline is a line continuation, not a character.
175    #[test]
176    fn line_continuation_contributes_nothing() {
177        assert_eq!(decode_json_string("a\\\nb"), "ab");
178        assert_eq!(decode_json_string("a\\\r\nb"), "ab");
179    }
180
181    /// A surrogate pair is one scalar; an unpaired high surrogate is yajl's `?`.
182    #[test]
183    fn unicode_escapes_and_surrogate_pairs() {
184        assert_eq!(decode_json_string("\\u0041"), "A");
185        assert_eq!(decode_json_string("\\uD83D\\uDE00"), "\u{1F600}");
186        assert_eq!(decode_json_string("\\uD83Dx"), "?x");
187    }
188
189    /// Text with no escapes is untouched — the common case must not be mangled.
190    #[test]
191    fn plain_text_is_untouched() {
192        assert_eq!(decode_json_string("hi there"), "hi there");
193        assert_eq!(decode_json_string("REC:AI.VAL"), "REC:AI.VAL");
194    }
195
196    /// The token form: only a quoted string is a string.
197    #[test]
198    fn only_a_quoted_token_is_a_string() {
199        assert_eq!(
200            decode_json_string_token(r#""a\tb""#).as_deref(),
201            Some("a\tb")
202        );
203        // Single-quoted text never reaches here: `json5::relaxed_to_strict`
204        // has already rewritten it to the double-quoted spelling.
205        assert_eq!(decode_json_string_token(r"'a\tb'"), None);
206        assert_eq!(decode_json_string_token("5"), None);
207        assert_eq!(decode_json_string_token("true"), None);
208        assert_eq!(decode_json_string_token("{a:1}"), None);
209    }
210}