Skip to main content

sim_lib_view/
universal_editor.rs

1//! The universal default editor: edit any value over one draft.
2//!
3//! The editor renders a draft in two real projections -- a readable `text`
4//! rendering and the codec-portable `raw` form (see [`EDIT_MODES`]). It
5//! validates before commit, anchors errors to the edited field, allows
6//! cancel/revert, preserves unknown fields when editing open maps (set
7//! semantics keep sibling keys), and refuses to commit a readonly value.
8//!
9//! The editor advertises only the two distinct projections below, so the mode
10//! list matches the behavior users can actually choose.
11
12use std::sync::Arc;
13
14use sim_kernel::{Cx, Diagnostic, Error, Expr, ExprKind, NumberLiteral, Result, ShapeRef, Symbol};
15use sim_lib_intent::{field, intent_kind_of};
16use sim_shape::{ExprKindShape, shape_value};
17use sim_value::path::{Path, PathError, get, set_at};
18
19use crate::contract::{Draft, Editor, Operation};
20
21/// The real edit-mode projections over one draft: a readable `text` rendering
22/// and the codec-portable `raw` form. These are the only two distinct
23/// projections [`render_draft`] produces, so only they are advertised.
24pub const EDIT_MODES: &[&str] = &["text", "raw"];
25
26/// The universal default editor.
27pub struct UniversalEditor {
28    readonly: bool,
29}
30
31impl UniversalEditor {
32    /// A writable universal editor.
33    pub fn writable() -> Self {
34        Self { readonly: false }
35    }
36
37    /// A read-only universal editor: it renders but never commits.
38    pub fn readonly() -> Self {
39        Self { readonly: true }
40    }
41}
42
43impl Editor for UniversalEditor {
44    fn decode(&self, _cx: &mut Cx, value: &Expr, intent: &Expr) -> Result<Draft> {
45        let Some(kind) = intent_kind_of(intent) else {
46            return Err(Error::HostError("editor input is not an Intent".to_owned()));
47        };
48        match &*kind.name {
49            "edit-field" => self.edit_field(value, intent),
50            "commit" => Ok(Draft::clean(value.clone(), value.clone())),
51            "cancel" => {
52                // Revert: discard any pending edit, proposing the base unchanged.
53                Ok(Draft::clean(value.clone(), value.clone()))
54            }
55            other => Ok(Draft::rejected(
56                value.clone(),
57                Diagnostic::error(format!("universal editor does not handle intent '{other}'")),
58            )),
59        }
60    }
61
62    fn commit(&self, _cx: &mut Cx, draft: &Draft) -> Result<Operation> {
63        if !draft.committable {
64            return Err(Error::HostError(
65                "draft is not committable; resolve diagnostics first".to_owned(),
66            ));
67        }
68        // The operation realizes by setting the resource to the proposed value.
69        Ok(Operation::new(Expr::Map(vec![
70            (
71                Expr::Symbol(Symbol::new("op")),
72                Expr::Symbol(Symbol::new("set-value")),
73            ),
74            (Expr::Symbol(Symbol::new("value")), draft.proposed.clone()),
75        ]))
76        .with_result_shape(expr_kind_shape(&draft.base)))
77    }
78}
79
80fn expr_kind_shape(expr: &Expr) -> ShapeRef {
81    let kind = match expr {
82        Expr::Nil => ExprKind::Nil,
83        Expr::Bool(_) => ExprKind::Bool,
84        Expr::Number(_) => ExprKind::Number,
85        Expr::Symbol(_) | Expr::Local(_) => ExprKind::Symbol,
86        Expr::String(_) => ExprKind::String,
87        Expr::Bytes(_) => ExprKind::Bytes,
88        Expr::List(_) => ExprKind::List,
89        Expr::Vector(_) => ExprKind::Vector,
90        Expr::Map(_) => ExprKind::Map,
91        Expr::Set(_) => ExprKind::Set,
92        Expr::Call { .. } => ExprKind::Call,
93        Expr::Infix { .. } => ExprKind::Infix,
94        Expr::Prefix { .. } => ExprKind::Prefix,
95        Expr::Postfix { .. } => ExprKind::Postfix,
96        Expr::Block(_) => ExprKind::Block,
97        Expr::Quote { .. } => ExprKind::Quote,
98        Expr::Annotated { .. } => ExprKind::Annotated,
99        Expr::Extension { .. } => ExprKind::Extension,
100    };
101    shape_value(
102        Symbol::qualified("core", format!("{kind:?}")),
103        Arc::new(ExprKindShape::new(kind)),
104    )
105}
106
107impl UniversalEditor {
108    fn edit_field(&self, value: &Expr, intent: &Expr) -> Result<Draft> {
109        if self.readonly {
110            return Ok(Draft::rejected(
111                value.clone(),
112                Diagnostic::error("value is read-only and cannot be edited"),
113            ));
114        }
115        let Some(path_expr @ Expr::List(_)) = field(intent, "path") else {
116            return Ok(Draft::rejected(
117                value.clone(),
118                Diagnostic::error("edit-field is missing a list 'path'"),
119            ));
120        };
121        let Some(new_value) = field(intent, "value") else {
122            return Ok(Draft::rejected(
123                value.clone(),
124                Diagnostic::error("edit-field is missing a 'value'"),
125            ));
126        };
127        let path = match Path::from_expr(path_expr) {
128            Ok(path) => path,
129            Err(error) => {
130                return Ok(Draft::rejected(
131                    value.clone(),
132                    path_error_diagnostic(path_expr, error),
133                ));
134            }
135        };
136        let replacement = match get(value, &path) {
137            Some(current) => match coerce_edit_value(current, new_value) {
138                Ok(value) => value,
139                Err(message) => {
140                    return Ok(Draft::rejected(
141                        value.clone(),
142                        field_shape_diagnostic(path_expr, message),
143                    ));
144                }
145            },
146            None => new_value.clone(),
147        };
148        match set_at(value, &path, replacement) {
149            Ok(proposed) => Ok(Draft::clean(value.clone(), proposed)),
150            Err(error) => Ok(Draft::rejected(
151                value.clone(),
152                path_error_diagnostic(path_expr, error),
153            )),
154        }
155    }
156}
157
158fn coerce_edit_value(current: &Expr, submitted: &Expr) -> core::result::Result<Expr, String> {
159    if !matches!(submitted, Expr::String(_)) {
160        return Ok(submitted.clone());
161    }
162    match current {
163        Expr::String(_) => match submitted {
164            Expr::String(_) => Ok(submitted.clone()),
165            other => Err(format!(
166                "string field received {}",
167                crate::universal_view::expr_kind(other)
168            )),
169        },
170        Expr::Bool(_) => coerce_bool(submitted),
171        Expr::Number(number) => coerce_number(number, submitted),
172        Expr::Symbol(symbol) => coerce_symbol(symbol, submitted).map(Expr::Symbol),
173        Expr::Local(symbol) => coerce_symbol(symbol, submitted).map(Expr::Local),
174        Expr::Nil => coerce_nil(submitted),
175        Expr::Bytes(_) => match submitted {
176            Expr::Bytes(_) => Ok(submitted.clone()),
177            other => Err(format!(
178                "bytes field received {}",
179                crate::universal_view::expr_kind(other)
180            )),
181        },
182        Expr::Map(_)
183        | Expr::List(_)
184        | Expr::Vector(_)
185        | Expr::Set(_)
186        | Expr::Call { .. }
187        | Expr::Infix { .. }
188        | Expr::Prefix { .. }
189        | Expr::Postfix { .. }
190        | Expr::Block(_)
191        | Expr::Quote { .. }
192        | Expr::Annotated { .. }
193        | Expr::Extension { .. } => Ok(submitted.clone()),
194    }
195}
196
197fn coerce_bool(submitted: &Expr) -> core::result::Result<Expr, String> {
198    match submitted {
199        Expr::Bool(_) => Ok(submitted.clone()),
200        Expr::String(text) => match text.trim() {
201            "true" => Ok(Expr::Bool(true)),
202            "false" => Ok(Expr::Bool(false)),
203            _ => Err(format!("bool field cannot parse {text:?}")),
204        },
205        other => Err(format!(
206            "bool field received {}",
207            crate::universal_view::expr_kind(other)
208        )),
209    }
210}
211
212fn coerce_number(current: &NumberLiteral, submitted: &Expr) -> core::result::Result<Expr, String> {
213    match submitted {
214        Expr::Number(_) => Ok(submitted.clone()),
215        Expr::String(text) => {
216            let canonical = text.trim();
217            validate_number_text(&current.canonical, canonical)?;
218            Ok(Expr::Number(NumberLiteral {
219                domain: current.domain.clone(),
220                canonical: canonical.to_owned(),
221            }))
222        }
223        other => Err(format!(
224            "number field received {}",
225            crate::universal_view::expr_kind(other)
226        )),
227    }
228}
229
230fn validate_number_text(old: &str, new: &str) -> core::result::Result<(), String> {
231    if new.is_empty() {
232        return Err("number field cannot be empty".to_owned());
233    }
234    if old.parse::<i128>().is_ok() {
235        new.parse::<i128>()
236            .map(|_| ())
237            .map_err(|_| format!("integer field cannot parse {new:?}"))
238    } else if old.parse::<f64>().is_ok() {
239        match new.parse::<f64>() {
240            Ok(value) if value.is_finite() => Ok(()),
241            _ => Err(format!("number field cannot parse {new:?}")),
242        }
243    } else if new.chars().any(char::is_control) {
244        Err("number field cannot contain control characters".to_owned())
245    } else {
246        Ok(())
247    }
248}
249
250fn coerce_symbol(current: &Symbol, submitted: &Expr) -> core::result::Result<Symbol, String> {
251    match submitted {
252        Expr::Symbol(symbol) | Expr::Local(symbol) => Ok(symbol.clone()),
253        Expr::String(text) => parse_symbol_like(current, text),
254        other => Err(format!(
255            "symbol field received {}",
256            crate::universal_view::expr_kind(other)
257        )),
258    }
259}
260
261fn parse_symbol_like(current: &Symbol, text: &str) -> core::result::Result<Symbol, String> {
262    let text = text.trim();
263    if text.is_empty() {
264        return Err("symbol field cannot be empty".to_owned());
265    }
266    if current.namespace.is_some()
267        && let Some((namespace, name)) = text.split_once('/')
268    {
269        if namespace.is_empty() || name.is_empty() || name.contains('/') {
270            return Err(format!("symbol field cannot parse {text:?}"));
271        }
272        return Ok(Symbol::qualified(namespace.to_owned(), name.to_owned()));
273    }
274    Symbol::checked(text.to_owned()).map_err(|error| error.to_string())
275}
276
277fn coerce_nil(submitted: &Expr) -> core::result::Result<Expr, String> {
278    match submitted {
279        Expr::Nil => Ok(Expr::Nil),
280        Expr::String(text) if text.trim() == "nil" => Ok(Expr::Nil),
281        other => Err(format!(
282            "nil field received {}",
283            crate::universal_view::expr_kind(other)
284        )),
285    }
286}
287
288/// Render a draft in one of the real edit modes (`text` readable, `raw`
289/// codec-portable; see [`EDIT_MODES`]). Both are views over the same
290/// `draft.proposed`, so switching mode never changes the draft. Any unknown
291/// mode renders the readable form.
292pub fn render_draft(draft: &Draft, mode: &str) -> Result<Expr> {
293    let proposed = &draft.proposed;
294    // `raw` shows the codec-portable encoding; every other mode renders the
295    // readable form.
296    let body = match mode {
297        "raw" => sim_codec::encode_portable(sim_kernel::CodecId(0), proposed)
298            .unwrap_or_else(|_| crate::universal_view::render_value(proposed)),
299        _ => crate::universal_view::render_value(proposed),
300    };
301    Ok(sim_lib_scene::node(
302        "box",
303        vec![
304            ("role", Expr::Symbol(Symbol::new("edit"))),
305            ("mode", Expr::Symbol(Symbol::new(mode))),
306            (
307                "children",
308                Expr::List(vec![
309                    sim_lib_scene::node(
310                        "field",
311                        vec![
312                            ("input-kind", Expr::Symbol(Symbol::new("text"))),
313                            ("value", Expr::String(body)),
314                            ("readonly", Expr::Bool(false)),
315                        ],
316                    ),
317                    committable_badge(draft),
318                ]),
319            ),
320        ],
321    ))
322}
323
324fn committable_badge(draft: &Draft) -> Expr {
325    if draft.committable {
326        sim_lib_scene::node(
327            "badge",
328            vec![
329                ("status", Expr::Symbol(Symbol::new("ok"))),
330                ("label", Expr::String("ready to commit".to_owned())),
331            ],
332        )
333    } else {
334        let message = draft
335            .diagnostics
336            .first()
337            .map(|diagnostic| diagnostic.message.clone())
338            .unwrap_or_else(|| "not committable".to_owned());
339        sim_lib_scene::node(
340            "badge",
341            vec![
342                ("status", Expr::Symbol(Symbol::new("error"))),
343                ("label", Expr::String(message)),
344            ],
345        )
346    }
347}
348
349/// Turn a `sim_value::path` failure into a field-anchored diagnostic naming the
350/// edit path. The set itself is the shared `sim_value::path::set_at` primitive.
351fn path_error_diagnostic(path: &Expr, error: PathError) -> Diagnostic {
352    Diagnostic::error(format!(
353        "edit rejected at path {}: {error:?}",
354        crate::universal_view::render_value(path)
355    ))
356}
357
358fn field_shape_diagnostic(path: &Expr, message: String) -> Diagnostic {
359    Diagnostic::error(format!(
360        "edit rejected at path {}: {message}",
361        crate::universal_view::render_value(path)
362    ))
363}