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 {
50        keyword: &'static str,
51        message: String,
52    },
53}
54
55/// Compile blue source into a Rust domain type.
56///
57/// The program's final value must be a map: the domain's fields as labels.
58///
59/// ```ignore
60/// #[derive(tatara_lisp::DeriveTataraDomain)]
61/// #[tatara(keyword = "defreplica")]
62/// struct Replica { name: String, max_replicas: i64 }
63///
64/// let r: Replica = blue_lang_runtime::domain::compile(r#"{name: "api", max_replicas: 3}"#)?;
65/// ```
66///
67/// # Errors
68///
69/// Every failure is a [`DomainError`] variant: the program did not run, it did
70/// not produce a map, a key or value has no domain spelling, or the domain
71/// refused the form.
72pub fn compile<T: TataraDomain>(src: &str) -> Result<T, DomainError> {
73    let value = run(src)?.value;
74    compile_value(&value)
75}
76
77/// Compile an already-evaluated blue value into a Rust domain type.
78///
79/// # Errors
80///
81/// As [`compile`], minus the run.
82pub fn compile_value<T: TataraDomain>(value: &Value) -> Result<T, DomainError> {
83    let form = domain_form(T::KEYWORD, value)?;
84    T::compile_from_sexp(&form).map_err(|e| DomainError::Refused {
85        keyword: T::KEYWORD,
86        message: e.to_string(),
87    })
88}
89
90/// The canonical `(KEYWORD :kebab-key v …)` form for a blue map.
91///
92/// Public so a caller can also WRITE the form: printed, it is the `.tlisp` text
93/// a hand author would have written.
94///
95/// # Errors
96///
97/// [`DomainError::NotAMap`], [`DomainError::KeyNotALabel`] or
98/// [`DomainError::Unrepresentable`].
99pub fn domain_form(keyword: &str, value: &Value) -> Result<Sexp, DomainError> {
100    let Value::Map(map) = value else {
101        return Err(DomainError::NotAMap {
102            found: kind_of(value),
103        });
104    };
105    let mut form = vec![Sexp::Atom(Atom::Symbol(keyword.to_string()))];
106    form.extend(keyword_args(map)?);
107    Ok(Sexp::List(form))
108}
109
110/// `:kebab-key v` pairs for a map, keys sorted so one map yields one form.
111fn keyword_args(map: &tatara_lisp_eval::Map) -> Result<Vec<Sexp>, DomainError> {
112    let mut pairs: Vec<(String, &Value)> = map
113        .iter()
114        .map(|(k, v)| label_of(k).map(|label| (label, v)))
115        .collect::<Result<_, _>>()?;
116    pairs.sort_by(|a, b| a.0.cmp(&b.0));
117
118    let mut args = Vec::with_capacity(pairs.len() * 2);
119    for (label, v) in pairs {
120        let field = kebab(&label);
121        let arg = to_sexp(&field, v)?;
122        args.push(Sexp::Atom(Atom::Keyword(field)));
123        args.push(arg);
124    }
125    Ok(args)
126}
127
128/// A map key as a label. Blue writes `name:` as a keyword; a symbol is accepted
129/// for maps built by code rather than by literal.
130fn label_of(key: &MapKey) -> Result<String, DomainError> {
131    match key {
132        MapKey::Keyword(k) | MapKey::Symbol(k) => Ok(k.to_string()),
133        MapKey::Str(_) => Err(DomainError::KeyNotALabel { found: "a string" }),
134        MapKey::Int(_) => Err(DomainError::KeyNotALabel {
135            found: "an integer",
136        }),
137        MapKey::Float(_) => Err(DomainError::KeyNotALabel { found: "a float" }),
138        MapKey::Bool(_) => Err(DomainError::KeyNotALabel { found: "a boolean" }),
139        MapKey::Nil => Err(DomainError::KeyNotALabel { found: "nil" }),
140    }
141}
142
143/// Blue's one spelling of a field to Lisp's one spelling: `max_replicas` →
144/// `max-replicas`. The same rule the derive applies to Rust field names
145/// (`snake_to_kebab`), so the two meet exactly.
146fn kebab(label: &str) -> String {
147    label.replace('_', "-")
148}
149
150/// A field value as a form the derive reads.
151fn to_sexp(field: &str, value: &Value) -> Result<Sexp, DomainError> {
152    Ok(match value {
153        Value::Nil => Sexp::Nil,
154        Value::Bool(b) => Sexp::Atom(Atom::Bool(*b)),
155        Value::Int(i) => Sexp::Atom(Atom::Int(*i)),
156        Value::Float(f) => Sexp::Atom(Atom::Float(*f)),
157        Value::Str(s) => Sexp::Atom(Atom::Str(s.to_string())),
158        Value::Symbol(s) => Sexp::Atom(Atom::Symbol(s.to_string())),
159        Value::Keyword(k) => Sexp::Atom(Atom::Keyword(kebab(k))),
160        Value::List(items) => Sexp::List(
161            items
162                .iter()
163                .map(|v| to_sexp(field, v))
164                .collect::<Result<_, _>>()?,
165        ),
166        Value::Map(map) => Sexp::List(keyword_args(map)?),
167        Value::Sexp(s, _) => s.clone(),
168        other => {
169            return Err(DomainError::Unrepresentable {
170                field: field.to_string(),
171                found: kind_of(other),
172            })
173        }
174    })
175}
176
177fn kind_of(value: &Value) -> &'static str {
178    match value {
179        Value::Nil => "nil",
180        Value::Bool(_) => "a boolean",
181        Value::Int(_) => "an integer",
182        Value::Float(_) => "a float",
183        Value::Str(_) => "a string",
184        Value::Symbol(_) => "a symbol",
185        Value::Keyword(_) => "a keyword",
186        Value::List(_) => "a list",
187        Value::Map(_) => "a map",
188        Value::Closure(_) | Value::NativeFn(_) => "a function",
189        Value::Promise(_) => "a promise",
190        Value::Error(_) => "an error object",
191        Value::Sexp(..) => "a quoted form",
192        _ => "a host value",
193    }
194}