Skip to main content

blue_lang_runtime/
domain.rs

1//! Blue as the authoring surface for Rust types: a blue value becomes a
2//! `#[derive(TataraDomain)]` struct.
3//!
4//! A `TataraDomain` compiles from exactly one shape, `(KEYWORD :kebab-key v …)`
5//! (`tatara_lisp::domain`, `compile_from_sexp`). Blue's map literal lowers to a
6//! different one: `{max_replicas: 3}` is `(hash-map :max_replicas 3)`, whose
7//! head is `hash-map` and whose keys keep blue's underscores, because a blue
8//! identifier cannot contain `-`. Two mismatches, and nothing converted between
9//! them, so no blue value could ever reach a Rust domain type.
10//!
11//! This module is that conversion, and it lives at blue's border rather than in
12//! tatara-lisp: teaching `TataraDomain` a second spelling of every key would give
13//! every Lisp author two ways to write one field. Here the one blue spelling is
14//! translated once into the one Lisp spelling:
15//!
16//! - the head becomes the domain's `KEYWORD`;
17//! - every key `max_replicas` becomes `:max-replicas`;
18//! - a nested map becomes a keyword list `(:k v …)`, the shape the derive reads
19//!   for struct-valued fields;
20//! - keys are emitted in sorted order, so one value always yields one form.
21//!
22//! [`compile`] is the whole round trip for a Rust caller: blue source in, a typed
23//! Rust value out, with every failure a typed [`DomainError`].
24
25use tatara_lisp::{Atom, Sexp, TataraDomain};
26use tatara_lisp_eval::{MapKey, Value};
27
28use crate::pipeline::{run, RunError};
29
30/// Why a blue value could not become a domain type.
31#[derive(Debug, thiserror::Error)]
32pub enum DomainError {
33    /// The blue program itself failed to parse, type-check or run.
34    #[error("the blue source did not run: {0}")]
35    Run(#[from] RunError),
36    /// A domain is built from a map; the program produced something else.
37    #[error("a domain is authored as a map (`{{key: value}}`), but the value was {found}")]
38    NotAMap { found: &'static str },
39    /// A map key blue can write that a domain cannot name (a number, a string).
40    #[error("domain keys are labels (`name:`), but a key was {found}")]
41    KeyNotALabel { found: &'static str },
42    /// A value with no Lisp spelling: a function, a promise, an error object.
43    #[error("the field `{field}` holds {found}, which has no form a domain can read")]
44    Unrepresentable { field: String, found: &'static str },
45    /// The form was well-shaped and the domain still refused it: an unknown
46    /// field, a missing required one, a wrong type. The message is the
47    /// domain's own, which names the field.
48    #[error("the domain `{keyword}` refused the form: {message}")]
49    Refused { keyword: &'static str, message: String },
50}
51
52/// Compile blue source into a Rust domain type.
53///
54/// The program's final value must be a map: the domain's fields as labels.
55///
56/// ```ignore
57/// #[derive(tatara_lisp::DeriveTataraDomain)]
58/// #[tatara(keyword = "defreplica")]
59/// struct Replica { name: String, max_replicas: i64 }
60///
61/// let r: Replica = blue_lang_runtime::domain::compile(r#"{name: "api", max_replicas: 3}"#)?;
62/// ```
63///
64/// # Errors
65///
66/// Every failure is a [`DomainError`] variant: the program did not run, it did
67/// not produce a map, a key or value has no domain spelling, or the domain
68/// refused the form.
69pub fn compile<T: TataraDomain>(src: &str) -> Result<T, DomainError> {
70    let value = run(src)?.value;
71    compile_value(&value)
72}
73
74/// Compile an already-evaluated blue value into a Rust domain type.
75///
76/// # Errors
77///
78/// As [`compile`], minus the run.
79pub fn compile_value<T: TataraDomain>(value: &Value) -> Result<T, DomainError> {
80    let form = domain_form(T::KEYWORD, value)?;
81    T::compile_from_sexp(&form).map_err(|e| DomainError::Refused {
82        keyword: T::KEYWORD,
83        message: e.to_string(),
84    })
85}
86
87/// The canonical `(KEYWORD :kebab-key v …)` form for a blue map.
88///
89/// Public so a caller can also WRITE the form: printed, it is the `.tlisp` text
90/// a hand author would have written.
91///
92/// # Errors
93///
94/// [`DomainError::NotAMap`], [`DomainError::KeyNotALabel`] or
95/// [`DomainError::Unrepresentable`].
96pub fn domain_form(keyword: &str, value: &Value) -> Result<Sexp, DomainError> {
97    let Value::Map(map) = value else {
98        return Err(DomainError::NotAMap { found: kind_of(value) });
99    };
100    let mut form = vec![Sexp::Atom(Atom::Symbol(keyword.to_string()))];
101    form.extend(keyword_args(map)?);
102    Ok(Sexp::List(form))
103}
104
105/// `:kebab-key v` pairs for a map, keys sorted so one map yields one form.
106fn keyword_args(
107    map: &std::collections::HashMap<MapKey, Value>,
108) -> Result<Vec<Sexp>, DomainError> {
109    let mut pairs: Vec<(String, &Value)> = map
110        .iter()
111        .map(|(k, v)| label_of(k).map(|label| (label, v)))
112        .collect::<Result<_, _>>()?;
113    pairs.sort_by(|a, b| a.0.cmp(&b.0));
114
115    let mut args = Vec::with_capacity(pairs.len() * 2);
116    for (label, v) in pairs {
117        let field = kebab(&label);
118        let arg = to_sexp(&field, v)?;
119        args.push(Sexp::Atom(Atom::Keyword(field)));
120        args.push(arg);
121    }
122    Ok(args)
123}
124
125/// A map key as a label. Blue writes `name:` as a keyword; a symbol is accepted
126/// for maps built by code rather than by literal.
127fn label_of(key: &MapKey) -> Result<String, DomainError> {
128    match key {
129        MapKey::Keyword(k) | MapKey::Symbol(k) => Ok(k.to_string()),
130        MapKey::Str(_) => Err(DomainError::KeyNotALabel { found: "a string" }),
131        MapKey::Int(_) => Err(DomainError::KeyNotALabel { found: "an integer" }),
132        MapKey::Float(_) => Err(DomainError::KeyNotALabel { found: "a float" }),
133        MapKey::Bool(_) => Err(DomainError::KeyNotALabel { found: "a boolean" }),
134        MapKey::Nil => Err(DomainError::KeyNotALabel { found: "nil" }),
135    }
136}
137
138/// Blue's one spelling of a field to Lisp's one spelling: `max_replicas` →
139/// `max-replicas`. The same rule the derive applies to Rust field names
140/// (`snake_to_kebab`), so the two meet exactly.
141fn kebab(label: &str) -> String {
142    label.replace('_', "-")
143}
144
145/// A field value as a form the derive reads.
146fn to_sexp(field: &str, value: &Value) -> Result<Sexp, DomainError> {
147    Ok(match value {
148        Value::Nil => Sexp::Nil,
149        Value::Bool(b) => Sexp::Atom(Atom::Bool(*b)),
150        Value::Int(i) => Sexp::Atom(Atom::Int(*i)),
151        Value::Float(f) => Sexp::Atom(Atom::Float(*f)),
152        Value::Str(s) => Sexp::Atom(Atom::Str(s.to_string())),
153        Value::Symbol(s) => Sexp::Atom(Atom::Symbol(s.to_string())),
154        Value::Keyword(k) => Sexp::Atom(Atom::Keyword(kebab(k))),
155        Value::List(items) => Sexp::List(
156            items
157                .iter()
158                .map(|v| to_sexp(field, v))
159                .collect::<Result<_, _>>()?,
160        ),
161        Value::Map(map) => Sexp::List(keyword_args(map)?),
162        Value::Sexp(s, _) => s.clone(),
163        other => {
164            return Err(DomainError::Unrepresentable {
165                field: field.to_string(),
166                found: kind_of(other),
167            })
168        }
169    })
170}
171
172fn kind_of(value: &Value) -> &'static str {
173    match value {
174        Value::Nil => "nil",
175        Value::Bool(_) => "a boolean",
176        Value::Int(_) => "an integer",
177        Value::Float(_) => "a float",
178        Value::Str(_) => "a string",
179        Value::Symbol(_) => "a symbol",
180        Value::Keyword(_) => "a keyword",
181        Value::List(_) => "a list",
182        Value::Map(_) => "a map",
183        Value::Closure(_) | Value::NativeFn(_) => "a function",
184        Value::Promise(_) => "a promise",
185        Value::Error(_) => "an error object",
186        Value::Sexp(..) => "a quoted form",
187        _ => "a host value",
188    }
189}