1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
//! sCalc's `READ`/`$R` (BIN_READ) and `WRITE`/`$W` (BIN_WRITE) are BINARY
//! operators, not unary ones: their element carries `runtime_effect` -1
//! (sCalcPostfix.c:177-195), exactly like `PRINTF` and `SSCANF`, so each pops
//! two operands and pushes one.
//!
//! The port declared them 1-arg and implemented them as TR_ESC / ESC in
//! disguise (bin_read = translate_escapes, bin_write = escape_string), so
//! `READ(AA)` silently computed an unescape instead of being the compile error
//! C reports, and the real binary conversion did not exist at all.
//!
//! Every expectation is the compiled synApps engine (sCalcPostfix.c +
//! sCalcPerform.c linked against the real epicsString.c, whose
//! `epicsStrnEscapedFromRaw` is what BIN_WRITE's output goes through).
use epics_base_rs::calc::{CalcError, StackValue, StringInputs, scalc};
fn write(fmt: &str, val: f64) -> Result<String, CalcError> {
let mut inputs = StringInputs::new();
inputs.str_vars[0] = fmt.into();
inputs.num_vars[0] = val;
match scalc("WRITE(AA,A)", &mut inputs)? {
StackValue::Str(s) => Ok(s.as_str_lossy().into_owned()),
StackValue::Double(d) => panic!("WRITE must produce a string, got {d}"),
}
}
fn read(subject: &str, fmt: &str) -> Result<f64, CalcError> {
let mut inputs = StringInputs::new();
inputs.str_vars[0] = subject.into();
inputs.str_vars[1] = fmt.into();
match scalc("READ(AA,BB)", &mut inputs)? {
StackValue::Double(d) => Ok(d),
StackValue::Str(s) => panic!("READ must produce a double, got {s:?}"),
}
}
/// The finding itself. One operand is CALC_ERR_INCOMPLETE (8) in C — "operand
/// missing" — because the element consumes two. The port compiled it happily.
///
/// C spells each of these twice, `READ`/`$R` and `WRITE`/`$W`, mapping to the
/// same BIN_READ/BIN_WRITE code. The `$` aliases are a separate gap (they are
/// in no port table yet, so they answer Syntax rather than Incomplete); when
/// they land they inherit this arity, because arity hangs off the opcode.
#[test]
fn one_operand_is_a_compile_error() {
let mut inputs = StringInputs::new();
for expr in ["READ(AA)", "WRITE(AA)"] {
assert_eq!(
scalc(expr, &mut inputs),
Err(CalcError::Incomplete),
"{expr}: READ/WRITE take two operands, so one is an incomplete expression"
);
}
}
/// Compiled C, `WRITE(AA,A)` with AA the format and A the value. The result is
/// the value's raw little-endian bytes, escaped by `epicsStrnEscapedFromRaw`:
/// NUL becomes `\0`, an unprintable byte becomes `\xNN`, a printable byte stays.
#[test]
fn write_lays_out_raw_little_endian_bytes() {
// 65 as int32 -> 41 00 00 00; 0x41 is 'A'.
assert_eq!(write("%d", 65.0).unwrap(), r"A\0\0\0");
// `h` narrows to int16 -> 41 00.
assert_eq!(write("%hd", 65.0).unwrap(), r"A\0");
// 1 as int32 -> 01 00 00 00; 0x01 is unprintable.
assert_eq!(write("%d", 1.0).unwrap(), r"\x01\0\0\0");
// -1 as int32 -> ff ff ff ff.
assert_eq!(write("%d", -1.0).unwrap(), r"\xff\xff\xff\xff");
// 65 as float32 -> 00 00 82 42; 0x42 is 'B'.
assert_eq!(write("%f", 65.0).unwrap(), r"\0\0\x82B");
// 1.5 as float32 -> 00 00 c0 3f; 0x3f is '?'.
assert_eq!(write("%f", 1.5).unwrap(), r"\0\0\xc0?");
// `l` widens to a full 8-byte double -> 00 00 00 00 00 40 50 40.
assert_eq!(write("%lf", 65.0).unwrap(), r"\0\0\0\0\0@P@");
// 'c' is a single byte.
assert_eq!(write("%c", 65.0).unwrap(), "A");
// The unsigned conversions are int32-wide too.
assert_eq!(write("%x", 65.0).unwrap(), r"A\0\0\0");
assert_eq!(write("%hx", 65.0).unwrap(), r"A\0");
}
/// Compiled C: a format with no conversion character is a runtime failure
/// (`return(-1)`), and so is `%s`.
#[test]
fn write_rejects_a_format_it_cannot_lay_out() {
assert!(write("abc", 1.0).is_err(), "no conversion character");
assert!(write("%s", 1.0).is_err(), "C explicitly returns -1 for 's'");
}
/// Compiled C: BIN_READ un-escapes the subject back to raw bytes and reads the
/// field out of them, producing a DOUBLE. These are the exact round-trips the
/// driver reports: READ(WRITE(fmt, v), fmt) == v.
#[test]
fn read_takes_the_field_back_out_of_the_bytes() {
assert_eq!(read(r"A\0", "%hd").unwrap(), 65.0);
assert_eq!(read(r"A\0\0\0", "%d").unwrap(), 65.0);
assert_eq!(read(r"\0\0\xc0?", "%f").unwrap(), 1.5);
assert_eq!(read(r"\0\0\0\0\0@P@", "%lf").unwrap(), 65.0);
assert_eq!(read("A", "%c").unwrap(), 65.0);
}
/// The narrow conversions sign-extend and the 4-byte one does NOT, because C's
/// `short h` and `char c` are exactly as wide as their field while `%d` is a
/// 4-byte `memcpy` into `long l = 0L` (sCalcPerform.c:321). Compiled C, subject
/// all-ones: %hd -1, %c -1, %hx 65535, %d 4294967295, %x 4294967295.
///
/// So in C a 4-byte `%d` and a 4-byte `%x` are the same read. That is a defect
/// in C, and it is C's answer.
#[test]
fn only_the_narrow_conversions_sign_extend() {
assert_eq!(read(r"\xff\xff", "%hd").unwrap(), -1.0);
assert_eq!(read(r"\xff", "%c").unwrap(), -1.0);
assert_eq!(read(r"\xff\xff", "%hx").unwrap(), 65535.0);
assert_eq!(read(r"\xff\xff\xff\xff", "%d").unwrap(), 4294967295.0);
assert_eq!(read(r"\xff\xff\xff\xff", "%x").unwrap(), 4294967295.0);
}
/// The round trip, written the way C's own test does it — as one expression, so
/// the two-operand stack discipline of both ops is exercised together.
#[test]
fn write_then_read_round_trips_in_one_expression() {
let mut inputs = StringInputs::new();
inputs.str_vars[0] = "%hd".into();
inputs.num_vars[0] = 7.0;
assert_eq!(
scalc("READ(WRITE(AA,A),AA)", &mut inputs).unwrap(),
StackValue::Double(7.0)
);
}
/// C `findConversionIndicator` skips an assignment-suppressed conversion, and
/// BIN_READ then skips that many BYTES of the subject before reading. `%*2hd`
/// means "step over two 2-byte shorts", so the value read is the third short.
#[test]
fn read_skips_a_suppressed_conversion() {
// Three int16s: 1, 2, 3. `%*2hd%hd` steps over the first two.
assert_eq!(read(r"\x01\0\x02\0\x03\0", "%*2hd%hd").unwrap(), 3.0);
}