Skip to main content

prosaic_common/
lib.rs

1//! Shared type metadata for the prosaic template engine.
2//!
3//! Owns `ValueType`, the `PIPE_SPECS` registry, and const-eval helpers used
4//! by both `prosaic-core` (at runtime) and `prosaic-derive` (at macro
5//! expansion time). This crate is `no_std` and has no dependencies so it
6//! can be included anywhere the other two crates run.
7
8#![no_std]
9
10/// A linguistic type that a template slot or pipe can carry.
11///
12/// Mirrors the variants of `prosaic_core::Value` plus `Any` as an
13/// escape hatch for pipes that accept heterogeneous inputs (such as
14/// `capitalize`, `verb`, `refer`, `possessive`).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum ValueType {
17    String,
18    Number,
19    List,
20    Entity,
21    /// Accepts any slot type. During type unification a concrete type
22    /// always wins over `Any` (e.g. `Number ∩ Any → Number`), so marking
23    /// a pipe's input `Any` never widens an already-inferred concrete
24    /// type.
25    Any,
26}
27
28#[cfg(test)]
29mod value_type_tests {
30    use super::*;
31
32    #[test]
33    fn variants_are_pairwise_distinct() {
34        let all = [
35            ValueType::String,
36            ValueType::Number,
37            ValueType::List,
38            ValueType::Entity,
39            ValueType::Any,
40        ];
41        for i in 0..all.len() {
42            for j in (i + 1)..all.len() {
43                assert_ne!(
44                    all[i], all[j],
45                    "variants at index {i} and {j} are unexpectedly equal"
46                );
47            }
48        }
49    }
50
51    #[test]
52    fn value_type_is_copy_and_eq() {
53        let a = ValueType::Number;
54        let b = a; // Copy
55        assert_eq!(a, b);
56        assert_ne!(ValueType::Number, ValueType::String);
57    }
58
59    #[test]
60    fn all_variants_are_const_constructible() {
61        const ALL: [ValueType; 5] = [
62            ValueType::String,
63            ValueType::Number,
64            ValueType::List,
65            ValueType::Entity,
66            ValueType::Any,
67        ];
68        // The const array proves every variant is const-constructible.
69        // Assert the length so this test fails if a variant is removed
70        // from the enum without also being removed from this array.
71        assert_eq!(ALL.len(), 5);
72    }
73}
74
75/// The type contract of a named pipe: the [`ValueType`] of its input
76/// value and the [`ValueType`] of its output.
77///
78/// Pipe-argument validation (e.g. that `truncate` has a numeric arg) is
79/// deliberately **not** modelled here — it remains a runtime check.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct PipeSpec {
82    pub name: &'static str,
83    pub input: ValueType,
84    pub output: ValueType,
85}
86
87/// The complete set of pipes recognised by the Prosaic engine.
88///
89/// This registry is the single source of truth shared between
90/// `prosaic-core::engine::apply_pipe` (dispatch) and
91/// `prosaic-derive::prosaic_template!` (compile-time validation).
92///
93/// Adding a new pipe: add a `PipeSpec` here, add a match arm in
94/// `prosaic-core::engine::apply_pipe`, and the `prosaic_template!` macro
95/// will automatically recognise it.
96///
97/// Feature-gated pipes: `relative` and `since_last` are listed
98/// unconditionally so templates targeting time-enabled builds still
99/// validate. When `prosaic-core` is compiled without the `time` feature,
100/// using either pipe produces an `InvalidPipe` error at render time.
101// Ordering mirrors `prosaic-derive::VALID_PIPES` so the two tables read
102// identically when reviewed side by side. Lookup is a linear scan so
103// order has no behavioral impact — this is purely for maintainability.
104pub const PIPE_SPECS: &[PipeSpec] = &[
105    PipeSpec {
106        name: "plural",
107        input: ValueType::Number,
108        output: ValueType::String,
109    },
110    PipeSpec {
111        name: "pluralize",
112        input: ValueType::Number,
113        output: ValueType::String,
114    },
115    PipeSpec {
116        name: "article",
117        input: ValueType::Any,
118        output: ValueType::String,
119    },
120    PipeSpec {
121        name: "join",
122        input: ValueType::List,
123        output: ValueType::String,
124    },
125    PipeSpec {
126        name: "ordinal",
127        input: ValueType::Number,
128        output: ValueType::String,
129    },
130    PipeSpec {
131        name: "words",
132        input: ValueType::Number,
133        output: ValueType::String,
134    },
135    PipeSpec {
136        name: "truncate",
137        input: ValueType::List,
138        output: ValueType::List,
139    },
140    PipeSpec {
141        name: "capitalize",
142        input: ValueType::Any,
143        output: ValueType::String,
144    },
145    PipeSpec {
146        name: "refer",
147        input: ValueType::Any,
148        output: ValueType::String,
149    },
150    PipeSpec {
151        name: "possessive",
152        input: ValueType::Any,
153        output: ValueType::String,
154    },
155    PipeSpec {
156        name: "verb",
157        input: ValueType::Any,
158        output: ValueType::String,
159    },
160    PipeSpec {
161        name: "syn",
162        input: ValueType::Any,
163        output: ValueType::String,
164    },
165    PipeSpec {
166        name: "relative",
167        input: ValueType::Number,
168        output: ValueType::String,
169    },
170    PipeSpec {
171        name: "since_last",
172        input: ValueType::Number,
173        output: ValueType::String,
174    },
175    PipeSpec {
176        name: "quantify",
177        input: ValueType::Number,
178        output: ValueType::String,
179    },
180    PipeSpec {
181        name: "proportion",
182        input: ValueType::Number,
183        output: ValueType::String,
184    },
185    PipeSpec {
186        name: "hedge",
187        input: ValueType::Number,
188        output: ValueType::String,
189    },
190    PipeSpec {
191        name: "negated",
192        input: ValueType::Any,
193        output: ValueType::String,
194    },
195    PipeSpec {
196        name: "choose",
197        input: ValueType::Any,
198        output: ValueType::String,
199    },
200    PipeSpec {
201        name: "demonstrative",
202        input: ValueType::Any,
203        output: ValueType::String,
204    },
205];
206
207/// Returns `true` when a value of type `actual` can satisfy a slot or
208/// pipe that expects type `expected`. `ValueType::Any` is compatible
209/// with every concrete type in either direction; concrete types are
210/// compatible only with themselves.
211///
212/// Compatibility is symmetric: `types_compatible(a, b) == types_compatible(b, a)`.
213/// The narrowing behaviour during unification (e.g. `Number ∩ Any → Number`) is
214/// handled by the caller (see `Template::infer_types`) — this function only
215/// answers the binary "is this assignment legal?" question.
216// Clippy wants `matches!(...)` here, but `matches!` is not stable in `const fn`
217// contexts — and this function MUST stay `const fn` because it is called from
218// the compile-time assertion blocks emitted by `prosaic_template!`.
219#[allow(clippy::match_like_matches_macro)]
220pub const fn types_compatible(actual: ValueType, expected: ValueType) -> bool {
221    match (actual, expected) {
222        (ValueType::Any, _) | (_, ValueType::Any) => true,
223        (ValueType::String, ValueType::String) => true,
224        (ValueType::Number, ValueType::Number) => true,
225        (ValueType::List, ValueType::List) => true,
226        (ValueType::Entity, ValueType::Entity) => true,
227        _ => false,
228    }
229}
230
231/// Look up a pipe by name. `const fn` so it is usable inside `const _: () = { ... }`
232/// assertion blocks emitted by the `prosaic_template!` macro.
233pub const fn pipe_spec(name: &str) -> Option<&'static PipeSpec> {
234    let mut i = 0;
235    while i < PIPE_SPECS.len() {
236        if byte_eq(PIPE_SPECS[i].name.as_bytes(), name.as_bytes()) {
237            return Some(&PIPE_SPECS[i]);
238        }
239        i += 1;
240    }
241    None
242}
243
244/// Look up a slot's declared [`ValueType`] in a `HasProsaicSchema`-style
245/// schema slice. `const fn` so it is usable inside compile-time assertion
246/// blocks emitted by the `prosaic_template!` macro.
247///
248/// Matching is byte-exact and case-sensitive. If the schema contains
249/// duplicate keys, the first entry wins.
250pub const fn schema_lookup(schema: &[(&str, ValueType)], slot: &str) -> Option<ValueType> {
251    let mut i = 0;
252    while i < schema.len() {
253        if byte_eq(schema[i].0.as_bytes(), slot.as_bytes()) {
254            return Some(schema[i].1);
255        }
256        i += 1;
257    }
258    None
259}
260
261/// Byte-wise equality, usable in `const fn` (unlike `str::eq`).
262/// Internal helper — not part of the public contract.
263pub(crate) const fn byte_eq(a: &[u8], b: &[u8]) -> bool {
264    if a.len() != b.len() {
265        return false;
266    }
267    let mut i = 0;
268    while i < a.len() {
269        if a[i] != b[i] {
270            return false;
271        }
272        i += 1;
273    }
274    true
275}
276
277#[cfg(test)]
278mod pipe_spec_tests {
279    use super::*;
280
281    #[test]
282    fn all_twenty_pipes_are_registered() {
283        assert_eq!(PIPE_SPECS.len(), 20);
284    }
285
286    #[test]
287    fn pluralize_is_number_to_string() {
288        let p = pipe_spec("pluralize").expect("pluralize must be registered");
289        assert_eq!(p.input, ValueType::Number);
290        assert_eq!(p.output, ValueType::String);
291    }
292
293    #[test]
294    fn truncate_is_list_to_list_for_chain_compatibility() {
295        let p = pipe_spec("truncate").expect("truncate must be registered");
296        assert_eq!(p.input, ValueType::List);
297        assert_eq!(p.output, ValueType::List, "truncate must chain into join");
298    }
299
300    #[test]
301    fn join_is_list_to_string() {
302        let p = pipe_spec("join").expect("join must be registered");
303        assert_eq!(p.input, ValueType::List);
304        assert_eq!(p.output, ValueType::String);
305    }
306
307    #[test]
308    fn refer_is_any_to_string() {
309        let p = pipe_spec("refer").expect("refer must be registered");
310        assert_eq!(p.input, ValueType::Any);
311        assert_eq!(p.output, ValueType::String);
312    }
313
314    #[test]
315    fn possessive_is_any_to_string() {
316        let p = pipe_spec("possessive").expect("possessive must be registered");
317        assert_eq!(p.input, ValueType::Any);
318        assert_eq!(p.output, ValueType::String);
319    }
320
321    #[test]
322    fn unknown_pipe_lookup_returns_none() {
323        assert!(pipe_spec("nonexistent").is_none());
324    }
325
326    #[test]
327    fn pipe_spec_lookup_is_const_evaluable() {
328        const SPEC: Option<&'static PipeSpec> = pipe_spec("pluralize");
329        // Verify both const evaluation worked AND returned the right data.
330        // Using matches! lets us destructure inside a const-context assertion
331        // without relying on PartialEq on references.
332        assert!(
333            matches!(SPEC, Some(s) if s.input == ValueType::Number && s.output == ValueType::String)
334        );
335    }
336
337    #[test]
338    fn pipe_spec_unknown_is_const_evaluable() {
339        const MISSING: Option<&'static PipeSpec> = pipe_spec("nonexistent_pipe_xyz");
340        assert!(MISSING.is_none());
341    }
342}
343
344#[cfg(test)]
345mod types_compatible_tests {
346    use super::*;
347
348    #[test]
349    fn any_matches_every_concrete_type() {
350        assert!(types_compatible(ValueType::Any, ValueType::Number));
351        assert!(types_compatible(ValueType::Number, ValueType::Any));
352        assert!(types_compatible(ValueType::Any, ValueType::Any));
353    }
354
355    #[test]
356    fn same_concrete_types_match() {
357        assert!(types_compatible(ValueType::Number, ValueType::Number));
358        assert!(types_compatible(ValueType::String, ValueType::String));
359        assert!(types_compatible(ValueType::List, ValueType::List));
360        assert!(types_compatible(ValueType::Entity, ValueType::Entity));
361    }
362
363    #[test]
364    fn distinct_concrete_types_reject() {
365        assert!(!types_compatible(ValueType::Number, ValueType::String));
366        assert!(!types_compatible(ValueType::List, ValueType::Number));
367        assert!(!types_compatible(ValueType::String, ValueType::Entity));
368    }
369
370    #[test]
371    fn compat_is_const_evaluable() {
372        const {
373            assert!(types_compatible(ValueType::Number, ValueType::Number));
374            assert!(!types_compatible(ValueType::Number, ValueType::List));
375        }
376    }
377
378    #[test]
379    fn compatibility_is_symmetric() {
380        // Compatibility must be commutative: a future regression that adds a
381        // directional special case (e.g. "Any on the left but not the right")
382        // would violate the unification semantics.
383        assert_eq!(
384            types_compatible(ValueType::Number, ValueType::String),
385            types_compatible(ValueType::String, ValueType::Number),
386        );
387        assert_eq!(
388            types_compatible(ValueType::Number, ValueType::Any),
389            types_compatible(ValueType::Any, ValueType::Number),
390        );
391        assert_eq!(
392            types_compatible(ValueType::List, ValueType::Entity),
393            types_compatible(ValueType::Entity, ValueType::List),
394        );
395    }
396}
397
398#[cfg(test)]
399mod schema_lookup_tests {
400    use super::*;
401
402    const FIXTURE: &[(&str, ValueType)] = &[
403        ("name", ValueType::String),
404        ("count", ValueType::Number),
405        ("items", ValueType::List),
406        ("actor", ValueType::Entity),
407    ];
408
409    #[test]
410    fn found_key_returns_type() {
411        assert_eq!(schema_lookup(FIXTURE, "name"), Some(ValueType::String));
412        assert_eq!(schema_lookup(FIXTURE, "count"), Some(ValueType::Number));
413        assert_eq!(schema_lookup(FIXTURE, "items"), Some(ValueType::List));
414        assert_eq!(schema_lookup(FIXTURE, "actor"), Some(ValueType::Entity));
415    }
416
417    #[test]
418    fn missing_key_returns_none() {
419        assert_eq!(schema_lookup(FIXTURE, "absent"), None);
420    }
421
422    #[test]
423    fn empty_schema_returns_none() {
424        assert_eq!(schema_lookup(&[], "anything"), None);
425    }
426
427    #[test]
428    fn lookup_is_const_evaluable() {
429        const FOUND: Option<ValueType> = schema_lookup(FIXTURE, "count");
430        const MISSING: Option<ValueType> = schema_lookup(FIXTURE, "absent");
431        assert_eq!(FOUND, Some(ValueType::Number));
432        assert_eq!(MISSING, None);
433    }
434
435    #[test]
436    fn similar_but_longer_key_does_not_match() {
437        // Guards against accidental prefix matching in byte_eq.
438        assert_eq!(schema_lookup(FIXTURE, "namer"), None);
439        assert_eq!(schema_lookup(FIXTURE, "nam"), None);
440    }
441
442    #[test]
443    fn duplicate_keys_return_first_match() {
444        // Documents the first-match contract — if a schema contains
445        // duplicate keys (shouldn't happen with the derive but could with
446        // hand-authored impls), the first entry wins.
447        const DUPE: &[(&str, ValueType)] = &[("x", ValueType::Number), ("x", ValueType::String)];
448        assert_eq!(schema_lookup(DUPE, "x"), Some(ValueType::Number));
449    }
450
451    #[test]
452    fn lookup_is_case_sensitive() {
453        // Comparison is byte-exact — case folding is out of scope.
454        assert_eq!(schema_lookup(FIXTURE, "Name"), None);
455        assert_eq!(schema_lookup(FIXTURE, "NAME"), None);
456        assert_eq!(schema_lookup(FIXTURE, "ACTOR"), None);
457    }
458}