1use 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
21pub const EDIT_MODES: &[&str] = &["text", "raw"];
25
26pub struct UniversalEditor {
28 readonly: bool,
29}
30
31impl UniversalEditor {
32 pub fn writable() -> Self {
34 Self { readonly: false }
35 }
36
37 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 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 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(¤t.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
288pub fn render_draft(draft: &Draft, mode: &str) -> Result<Expr> {
293 let proposed = &draft.proposed;
294 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
349fn 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}