beck_prim/lib.rs
1//! The runtime library: the primitives a compiled program links against.
2//!
3//! # What this is
4//!
5//! A handful of Beck's primitives are neither arithmetic nor a shape the code generators can lay
6//! out: a digest is somebody's table, base64 is somebody's grammar, case mapping is Unicode's
7//! table, and `str_to_int` has to agree with Rust's own parser about every input that is not a
8//! number. Both native backends refused all of them, and the refusal said so in as many words.
9//!
10//! There were three ways to answer that, and only one of them is fast **and** exact:
11//!
12//! * **Ask the host.** The four `nondet` primitives do
13//! ([`beck_llvm::Upcall`](../beck_llvm/emit/enum.Upcall.html)), because a clock and an id source
14//! are questions only the process outside can answer. A digest is not a question — it is a
15//! function of its argument — and a pipe round trip per call would make the compiled program
16//! *slower* than the tree-walker at the thing it exists to be faster at.
17//! * **Emit the algorithm.** Hex and base64 would compile; BLAKE3 and a Unicode case table would
18//! be a second implementation of somebody else's specification, sitting beside the one the
19//! evaluator already calls and agreeing with it only as far as it had been tested.
20//! * **Link the implementation.** This crate. The compiled program calls the **same Rust
21//! functions** the evaluator calls, so the differential between the backends is not a claim
22//! about two implementations agreeing; there is one.
23//!
24//! So this crate is the standard library's host half, in one place: [`digest`] and [`time`] moved
25//! here from `beck-core` and `beck-eval` rather than being copied, [`text`] is where the three
26//! primitives that are a `str::` method live, and the evaluator calls all of it.
27//!
28//! # The two exports, and why there is no pointer in them
29//!
30//! `abi` — the module, behind the feature of the same name — is the C entry points, and the
31//! interesting property is what does **not** cross them.
32//! The workspace forbids `unsafe_code`, and a runtime library that took `(*const u8, usize)` and
33//! made a slice out of it would need an `unsafe` block in the first line of every primitive. So
34//! the arena is turned around: this crate **owns** the compiled program's heap ([`arena`]), the
35//! program is handed its base once at startup, and every call after that carries **offsets**. An
36//! offset is an `i64`, reading one is an indexing operation on a `Vec<u8>` this crate holds, and a
37//! bad offset is a panic rather than a fault — so there is no raw pointer dereference here, no
38//! `unsafe` block, and nothing for `docs/43-threat-model.md`'s structural claim to give up.
39//!
40//! `adr/0026` had already made a value in that arena an **offset and not a pointer**, so that the
41//! whole heap could cross a pipe as bytes. This crate is the second thing that property buys.
42//!
43//! # The protocol
44//!
45//! `beck_prim`, and a mark rather than a return value:
46//!
47//! 1. The caller passes the arena's high-water mark and up to three argument words.
48//! 2. This library allocates what its answer needs, starting at that mark.
49//! 3. It writes a **two-word outcome record** — a [`Status`] and a word — immediately *above*
50//! everything it allocated, and answers with that offset.
51//! 4. The caller stores the answer as its new mark and reads the record from it.
52//!
53//! The record sitting above the mark is what makes a call cost no arena at all beyond its answer:
54//! it is scratch, live until the next allocation, which is exactly as long as the caller needs it.
55//! An arena with no room is `-1`, which the caller turns into its own heap-exhausted trap with the
56//! span it already has.
57//!
58//! There is deliberately **no error path for a bad offset**. A caller here is a code generator in
59//! this workspace, not a program, and a defensive answer would be a second contract to keep in
60//! agreement with the first.
61//!
62//! # The second entry point, which carries no offsets at all
63//!
64//! [`math`] is here for a different reason from the rest of this crate. A digest is linked because
65//! emitting it would be a second implementation of somebody else's table; a sine is linked because
66//! **there is no answer to ask the host for** — IEEE 754 pins `sqrt` to one correctly-rounded
67//! result and pins neither `sin` nor `cos`, so a fold that reaches the platform's libm replays to
68//! a different state on a platform with a different one.
69//!
70//! A function from a double to a double needs no heap, so `beck_prim_f64` takes the argument
71//! itself and answers the result. Routing it through the mark protocol above would cost a lock, a
72//! bounds check and an outcome record per call, for a primitive whose whole input is 64 bits — and
73//! the arena's first paragraph, which is what buys all of that, has nothing to buy here.
74
75#[cfg(feature = "abi")]
76pub mod abi;
77pub mod arena;
78pub mod digest;
79pub mod math;
80pub mod text;
81pub mod time;
82
83/// What the first word of an outcome record says.
84///
85/// Three cases rather than two, because `str_to_int` answers `Option[Int]` and an `Option` is laid
86/// out by the code generator that asked: this library says *there is no value* and the emitter
87/// builds the `None` its own layout calls for. The same division is what makes a raise work — the
88/// library produces the message, the emitter builds the declared error value around it, because
89/// the type of that value is a fact about the program.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91#[repr(i64)]
92pub enum Status {
93 /// The second word is the answer: a scalar, or the offset of a `Str`.
94 Value = 0,
95 /// The second word is the offset of a `Str` saying why. The caller raises.
96 Raised = 1,
97 /// There is no value. The caller answers `None`.
98 Nothing = 2,
99}
100
101impl Status {
102 pub fn word(self) -> i64 {
103 self as i64
104 }
105}
106
107/// A primitive this library computes, and the code the two backends call it by.
108///
109/// The numbers are written out rather than derived from the order, because they are a protocol
110/// between a compiled program and a library it was linked against: a primitive removed from the
111/// middle of this list must not renumber the ones after it.
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum Op {
114 Digest = 1,
115 DigestKeyed = 2,
116 DigestEq = 3,
117 HexEncode = 4,
118 HexDecode = 5,
119 Base64Encode = 6,
120 Base64Decode = 7,
121 UuidParse = 8,
122 UuidVersion = 9,
123 StrUpper = 10,
124 StrLower = 11,
125 StrToInt = 12,
126 StrReplace = 13,
127 TimeFormat = 14,
128 TimeParse = 15,
129}
130
131impl Op {
132 /// Every one of them, so a caller can build a table without repeating the list.
133 pub const ALL: [Op; 15] = [
134 Op::Digest,
135 Op::DigestKeyed,
136 Op::DigestEq,
137 Op::HexEncode,
138 Op::HexDecode,
139 Op::Base64Encode,
140 Op::Base64Decode,
141 Op::UuidParse,
142 Op::UuidVersion,
143 Op::StrUpper,
144 Op::StrLower,
145 Op::StrToInt,
146 Op::StrReplace,
147 Op::TimeFormat,
148 Op::TimeParse,
149 ];
150
151 pub fn code(self) -> i32 {
152 self as i32
153 }
154
155 pub fn from_code(code: i32) -> Option<Op> {
156 Op::ALL.into_iter().find(|o| o.code() == code)
157 }
158
159 /// How many argument words the call carries.
160 ///
161 /// A property of the primitive rather than of the call, for the reason `beck_llvm`'s upcall
162 /// arity is one: an arity read out of the call is an arity nobody checked.
163 pub fn arity(self) -> usize {
164 match self {
165 Op::StrReplace => 3,
166 Op::DigestKeyed | Op::DigestEq => 2,
167 _ => 1,
168 }
169 }
170
171 /// How many of the argument words are the offset of a `Str`.
172 ///
173 /// The text arguments come first and there is only one primitive here whose argument is not
174 /// text at all, so this is a count rather than a mask — and it is what stops the ABI from
175 /// reading a millisecond as an offset.
176 pub fn text_args(self) -> usize {
177 match self {
178 Op::TimeFormat => 0,
179 _ => self.arity(),
180 }
181 }
182
183 /// The name the Beck program wrote, which is also `beck_core::Prim::name`.
184 pub fn name(self) -> &'static str {
185 match self {
186 Op::Digest => "digest",
187 Op::DigestKeyed => "digest_keyed",
188 Op::DigestEq => "digest_eq",
189 Op::HexEncode => "hex_encode",
190 Op::HexDecode => "hex_decode",
191 Op::Base64Encode => "base64_encode",
192 Op::Base64Decode => "base64_decode",
193 Op::UuidParse => "uuid_parse",
194 Op::UuidVersion => "uuid_version",
195 Op::StrUpper => "str_upper",
196 Op::StrLower => "str_lower",
197 Op::StrToInt => "str_to_int",
198 Op::StrReplace => "str_replace",
199 Op::TimeFormat => "time_format",
200 Op::TimeParse => "time_parse",
201 }
202 }
203
204 /// The declared value a failure of this primitive raises.
205 ///
206 /// `None` for one that cannot fail. This library produces the *message* and nothing else: the
207 /// value around it is a declared type with a layout, and a layout belongs to whichever code
208 /// generator asked. So the shape is described here, where the failure is, and built there.
209 pub fn raises(self) -> Option<Raise> {
210 let (ty, variant, constants): (_, _, &'static [(&'static str, &'static str)]) = match self {
211 Op::HexDecode => ("EncodingError", "BadEncoding", &[("encoding", "hex")]),
212 Op::Base64Decode => ("EncodingError", "BadEncoding", &[("encoding", "base64")]),
213 Op::UuidParse | Op::UuidVersion => ("UuidError", "BadUuid", &[]),
214 Op::TimeParse => ("TimeError", "BadTime", &[]),
215 _ => return None,
216 };
217 Some(Raise {
218 ty,
219 variant,
220 constants,
221 why: "why",
222 })
223 }
224}
225
226/// The value a primitive's failure raises, described rather than built.
227///
228/// Every field of it is here: the declared type, its variant, the fields whose values are a
229/// constant of the primitive rather than of the input, and the one field the message goes in. A
230/// caller that filled these in from its own table would be a second place for the evaluator's
231/// `EncodingError.BadEncoding(encoding = "hex", …)` to be written down.
232#[derive(Clone, Copy, Debug, PartialEq, Eq)]
233pub struct Raise {
234 pub ty: &'static str,
235 pub variant: &'static str,
236 /// Fields the primitive fixes: `hex_decode` always raises with `encoding = "hex"`.
237 pub constants: &'static [(&'static str, &'static str)],
238 /// The field the message goes in.
239 pub why: &'static str,
240}
241
242/// What one call answers, before it is written into the arena.
243///
244/// Owned, and that is the point: the argument words are read out of the arena by reference and
245/// this is produced from them, so the borrow ends before anything is written back.
246#[derive(Clone, Debug, PartialEq, Eq)]
247pub enum Answer {
248 /// A scalar — an `Int`, or a `Bool` as `0` and `1`.
249 Word(i64),
250 /// Text, to be allocated as a `Str`.
251 Text(String),
252 /// `None`.
253 Nothing,
254 /// A failure, with the message the declared error value carries.
255 Raised(String),
256}
257
258/// Perform one primitive over text already read out of the arena.
259///
260/// Separated from the ABI so that it can be tested without an arena at all, and so that the one
261/// place a `Prim` becomes an answer is a `match` a reader can check against `beck-eval`'s.
262pub fn perform(op: Op, args: &[&str], words: &[i64]) -> Answer {
263 match op {
264 Op::Digest => Answer::Text(digest::of(args[0])),
265 Op::DigestKeyed => Answer::Text(digest::keyed(args[0], args[1])),
266 Op::DigestEq => Answer::Word(i64::from(digest::same(args[0], args[1]))),
267 Op::HexEncode => Answer::Text(digest::hex_encode(args[0])),
268 Op::HexDecode => answer(digest::hex_decode(args[0])),
269 Op::Base64Encode => Answer::Text(digest::base64_encode(args[0])),
270 Op::Base64Decode => answer(digest::base64_decode(args[0])),
271 Op::UuidParse => answer(digest::uuid_normalise(args[0])),
272 Op::UuidVersion => match digest::uuid_normalise(args[0]) {
273 Ok(canonical) => Answer::Word(digest::uuid_version(&canonical)),
274 Err(why) => Answer::Raised(why),
275 },
276 Op::StrUpper => Answer::Text(text::upper(args[0])),
277 Op::StrLower => Answer::Text(text::lower(args[0])),
278 Op::StrToInt => match text::to_int(args[0]) {
279 Some(n) => Answer::Word(n),
280 None => Answer::Nothing,
281 },
282 Op::StrReplace => Answer::Text(text::replace(args[0], args[1], args[2])),
283 Op::TimeFormat => Answer::Text(time::format(words[0])),
284 Op::TimeParse => match time::parse(args[0]) {
285 Ok(ms) => Answer::Word(ms),
286 Err(why) => Answer::Raised(why),
287 },
288 }
289}
290
291fn answer(r: Result<String, String>) -> Answer {
292 match r {
293 Ok(text) => Answer::Text(text),
294 Err(why) => Answer::Raised(why),
295 }
296}
297
298/// A primitive that is a function from a double to a double, and the code the backends call it by.
299///
300/// A separate vocabulary from [`Op`] rather than two more of its variants, because the two share
301/// no part of their protocol: an [`Op`] reads text out of the arena and writes an outcome record
302/// above the mark, and one of these takes a number and answers one. Merging them would give every
303/// arm of the arena protocol a case that cannot happen.
304///
305/// The numbers are written out for [`Op`]'s reason: they are a protocol between a compiled program
306/// and a library it was linked against.
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum FloatOp {
309 Sin = 1,
310 Cos = 2,
311}
312
313impl FloatOp {
314 pub const ALL: [FloatOp; 2] = [FloatOp::Sin, FloatOp::Cos];
315
316 pub fn code(self) -> i32 {
317 self as i32
318 }
319
320 pub fn from_code(code: i32) -> Option<FloatOp> {
321 FloatOp::ALL.into_iter().find(|o| o.code() == code)
322 }
323
324 /// The name the Beck program wrote, which is also `beck_core::Prim::name`.
325 pub fn name(self) -> &'static str {
326 match self {
327 FloatOp::Sin => "sin",
328 FloatOp::Cos => "cos",
329 }
330 }
331}
332
333/// Perform one primitive that is a function from a double to a double.
334///
335/// Separated from the ABI so that the evaluator reaches the same `match` a compiled program does,
336/// which is what makes the three-way differential a statement about one implementation rather than
337/// about three that agree.
338pub fn perform_f64(op: FloatOp, x: f64) -> f64 {
339 match op {
340 FloatOp::Sin => math::sin(x),
341 FloatOp::Cos => math::cos(x),
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn every_code_reads_back_as_the_primitive_that_wrote_it() {
351 for op in Op::ALL {
352 assert_eq!(Op::from_code(op.code()), Some(op), "{}", op.name());
353 }
354 assert_eq!(Op::from_code(0), None, "no primitive is zero");
355 assert_eq!(Op::from_code(99), None);
356 // A duplicate code would make two primitives one call.
357 let mut codes: Vec<i32> = Op::ALL.iter().map(|o| o.code()).collect();
358 codes.sort_unstable();
359 codes.dedup();
360 assert_eq!(codes.len(), Op::ALL.len(), "the codes are distinct");
361 }
362
363 #[test]
364 fn a_primitive_that_can_fail_names_what_it_raises() {
365 // The pairing is what the emitter builds a value from, so a primitive whose answer can be
366 // `Raised` and which names no type would be a raise with nothing to raise.
367 for (op, args) in [
368 (Op::HexDecode, "zz"),
369 (Op::Base64Decode, "!"),
370 (Op::UuidParse, "nope"),
371 (Op::UuidVersion, "nope"),
372 (Op::TimeParse, "nope"),
373 ] {
374 assert!(
375 matches!(perform(op, &[args], &[]), Answer::Raised(_)),
376 "{} should fail on {args:?}",
377 op.name()
378 );
379 assert!(op.raises().is_some(), "{} names no error type", op.name());
380 }
381 }
382}