Skip to main content

mittens_engine/scripting/repl/
navigation.rs

1use crate::engine::ecs::{ComponentId, World};
2use crate::scripting::object::{CeChild, MaterializedCE, Value};
3use slotmap::KeyData;
4
5use super::format_repl_value;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum ReplInput {
9    Snippet(String),
10    Ls,
11    Pwd,
12    Cd(String),
13}
14
15pub fn parse_repl_input(source: String) -> ReplInput {
16    let trimmed = source.trim();
17    match trimmed {
18        "ls" => ReplInput::Ls,
19        "pwd" => ReplInput::Pwd,
20        _ => match trimmed.strip_prefix("cd") {
21            Some(rest) if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() => {
22                ReplInput::Cd(rest.trim().to_string())
23            }
24            _ => ReplInput::Snippet(source),
25        },
26    }
27}
28
29#[derive(Debug, Clone)]
30enum Cursor {
31    World,
32    Value(Value),
33}
34
35#[derive(Debug, Clone)]
36enum Anchor {
37    World,
38    Binding(String),
39    Expression,
40}
41
42#[derive(Debug, Clone)]
43struct Breadcrumb {
44    cursor: Cursor,
45    segment: String,
46}
47
48#[derive(Debug, Clone)]
49pub struct NavigationState {
50    cursor: Cursor,
51    anchor: Anchor,
52    breadcrumbs: Vec<Breadcrumb>,
53}
54
55impl Default for NavigationState {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl NavigationState {
62    pub fn new() -> Self {
63        Self {
64            cursor: Cursor::World,
65            anchor: Anchor::World,
66            breadcrumbs: Vec::new(),
67        }
68    }
69
70    pub fn reset(&mut self) {
71        *self = Self::new();
72    }
73
74    pub fn cwd_value(&self) -> Value {
75        match &self.cursor {
76            Cursor::World => Value::Identifier("__mms_world__".into()),
77            Cursor::Value(value) => value.clone(),
78        }
79    }
80
81    pub fn ensure_valid(&mut self, world: &World) -> Option<String> {
82        let Cursor::Value(Value::ComponentObject { id, .. }) = &self.cursor else {
83            return None;
84        };
85        if world.get_component_node(*id).is_some() {
86            return None;
87        }
88        let message =
89            format!("stale component handle: component {id:?} is not live; returned to /");
90        self.reset();
91        Some(message)
92    }
93
94    pub fn set_evaluated(
95        &mut self,
96        value: Value,
97        binding: Option<String>,
98        world: &World,
99    ) -> Result<(), String> {
100        ensure_navigable(&value, world)?;
101        self.cursor = Cursor::Value(value);
102        self.anchor = binding.map(Anchor::Binding).unwrap_or(Anchor::Expression);
103        self.breadcrumbs.clear();
104        Ok(())
105    }
106
107    pub fn cd_root(&mut self) {
108        self.reset();
109    }
110
111    pub fn cd_parent(&mut self, world: &World) {
112        if let Cursor::Value(Value::ComponentObject { id, .. }) = &self.cursor {
113            if let Some(parent) = world.parent_of(*id) {
114                self.cursor = Cursor::Value(component_value(world, parent));
115            } else {
116                self.reset();
117            }
118            return;
119        }
120        if let Some(parent) = self.breadcrumbs.pop() {
121            self.cursor = parent.cursor;
122        } else if !matches!(self.cursor, Cursor::World) {
123            self.reset();
124        }
125    }
126
127    pub fn cd_child(&mut self, segment: &str, world: &World) -> Result<(), String> {
128        let (value, label) = resolve_child(&self.cursor, segment, world)?;
129        ensure_navigable(&value, world)?;
130        let old = self.cursor.clone();
131        self.breadcrumbs.push(Breadcrumb {
132            cursor: old,
133            segment: label,
134        });
135        self.cursor = Cursor::Value(value);
136        Ok(())
137    }
138
139    pub fn cd_path(&mut self, path: &str, world: &World) -> Result<(), String> {
140        let previous = self.clone();
141        if path.starts_with('/') {
142            self.reset();
143        }
144        for segment in path.split('/').filter(|part| !part.is_empty()) {
145            let result = match segment {
146                "." => Ok(()),
147                ".." => {
148                    self.cd_parent(world);
149                    Ok(())
150                }
151                child => self.cd_child(child, world),
152            };
153            if let Err(error) = result {
154                *self = previous;
155                return Err(error);
156            }
157        }
158        Ok(())
159    }
160
161    pub fn pwd(&self, world: &World) -> String {
162        match &self.cursor {
163            Cursor::World => "/".into(),
164            Cursor::Value(Value::ComponentObject { id, .. }) => live_pwd(world, *id),
165            Cursor::Value(_) => {
166                let anchor = match &self.anchor {
167                    Anchor::World => "/".into(),
168                    Anchor::Binding(name) => format!("${name}"),
169                    Anchor::Expression => "<expression>".into(),
170                };
171                let suffix = self
172                    .breadcrumbs
173                    .iter()
174                    .map(|crumb| crumb.segment.as_str())
175                    .collect::<Vec<_>>()
176                    .join("/");
177                if suffix.is_empty() {
178                    anchor
179                } else {
180                    format!("{anchor}/{suffix}")
181                }
182            }
183        }
184    }
185
186    pub fn listing(&self, world: &World) -> Result<Vec<String>, String> {
187        match &self.cursor {
188            Cursor::World => Ok(world
189                .world_roots()
190                .into_iter()
191                .enumerate()
192                .filter_map(|(index, id)| {
193                    crate::engine::repl::util::format_ls_line(world, index, id)
194                })
195                .collect()),
196            Cursor::Value(Value::ComponentObject { id, .. }) => {
197                if world.get_component_node(*id).is_none() {
198                    return Err(format!(
199                        "stale component handle: component {id:?} is not live"
200                    ));
201                }
202                Ok(world
203                    .children_of(*id)
204                    .iter()
205                    .copied()
206                    .enumerate()
207                    .filter_map(|(index, id)| {
208                        crate::engine::repl::util::format_ls_line(world, index, id)
209                    })
210                    .collect())
211            }
212            Cursor::Value(value) => value_listing(value, world),
213        }
214    }
215}
216
217fn value_listing(value: &Value, world: &World) -> Result<Vec<String>, String> {
218    match value {
219        Value::Object(id) => id
220            .with_map(|map| table_listing(map, world))
221            .ok_or_else(|| String::from("stale evaluated table"))?,
222        Value::Map(map) => table_listing(map, world),
223        Value::Array(values) => values
224            .iter()
225            .enumerate()
226            .map(|(index, value)| Ok(format!("{index}: {}", format_repl_value(value, world)?)))
227            .collect(),
228        Value::ComponentExpr(ce) => ce
229            .children
230            .iter()
231            .enumerate()
232            .map(|(index, child)| match child {
233                CeChild::Spawn(ce) => Ok(format!(
234                    "{index}: {}",
235                    format_repl_value(&Value::ComponentExpr(Box::new(ce.clone())), world)?
236                )),
237                CeChild::Attach(id) => Ok(format!("{index}: <live component {id:?}>")),
238            })
239            .collect(),
240        other => Err(format!("unsupported navigation value: {other:?}")),
241    }
242}
243
244fn table_listing(
245    map: &std::collections::HashMap<String, Value>,
246    world: &World,
247) -> Result<Vec<String>, String> {
248    let mut keys = map.keys().cloned().collect::<Vec<_>>();
249    keys.sort();
250    keys.into_iter()
251        .enumerate()
252        .map(|(index, key)| {
253            Ok(format!(
254                "{index}: {key} = {}",
255                format_repl_value(&map[&key], world)?
256            ))
257        })
258        .collect()
259}
260
261fn resolve_child(cursor: &Cursor, segment: &str, world: &World) -> Result<(Value, String), String> {
262    match cursor {
263        Cursor::World => resolve_live(world.world_roots(), segment, world),
264        Cursor::Value(Value::ComponentObject { id, .. }) => {
265            if world.get_component_node(*id).is_none() {
266                return Err(format!(
267                    "stale component handle: component {id:?} is not live"
268                ));
269            }
270            resolve_live(world.children_of(*id).to_vec(), segment, world)
271        }
272        Cursor::Value(Value::Object(id)) => id
273            .with_map(|map| resolve_table(map, segment))
274            .ok_or_else(|| String::from("stale evaluated table"))?,
275        Cursor::Value(Value::Map(map)) => resolve_table(map, segment),
276        Cursor::Value(Value::Array(values)) => {
277            let index = segment
278                .parse::<usize>()
279                .map_err(|_| format!("array index must be numeric: {segment}"))?;
280            values
281                .get(index)
282                .cloned()
283                .map(|value| (value, index.to_string()))
284                .ok_or_else(|| format!("array index out of range: {index}"))
285        }
286        Cursor::Value(Value::ComponentExpr(ce)) => resolve_ce_child(ce, segment, world),
287        Cursor::Value(other) => Err(format!("unsupported navigation value: {other:?}")),
288    }
289}
290
291fn resolve_table(
292    map: &std::collections::HashMap<String, Value>,
293    segment: &str,
294) -> Result<(Value, String), String> {
295    let mut keys = map.keys().cloned().collect::<Vec<_>>();
296    keys.sort();
297    let key = if let Ok(index) = segment.parse::<usize>() {
298        keys.get(index)
299            .cloned()
300            .ok_or_else(|| format!("table index out of range: {index}"))?
301    } else {
302        segment.to_string()
303    };
304    map.get(&key)
305        .cloned()
306        .map(|value| (value, key.clone()))
307        .ok_or_else(|| format!("table field not found: {key}"))
308}
309
310fn resolve_ce_child(
311    ce: &MaterializedCE,
312    segment: &str,
313    world: &World,
314) -> Result<(Value, String), String> {
315    let children = ce
316        .children
317        .iter()
318        .map(|child| match child {
319            CeChild::Spawn(ce) => Value::ComponentExpr(Box::new(ce.clone())),
320            CeChild::Attach(id) => component_value(world, *id),
321        })
322        .collect::<Vec<_>>();
323    if let Ok(index) = segment.parse::<usize>() {
324        return children
325            .get(index)
326            .cloned()
327            .map(|value| (value, ce_child_label(&children[index], index)))
328            .ok_or_else(|| format!("component-expression index out of range: {index}"));
329    }
330    let authored_matches = children
331        .iter()
332        .enumerate()
333        .filter(|(_, value)| match value {
334            Value::ComponentExpr(ce) => authored_ce_name(ce).as_deref() == Some(segment),
335            _ => false,
336        })
337        .collect::<Vec<_>>();
338    if let [(index, value)] = authored_matches.as_slice() {
339        return Ok(((*value).clone(), ce_child_label(value, *index)));
340    }
341    if authored_matches.len() > 1 {
342        return Err(format!(
343            "ambiguous component-expression child name: {segment}; use its index"
344        ));
345    }
346    let matches = children
347        .iter()
348        .enumerate()
349        .filter(|(_, value)| match value {
350            Value::ComponentExpr(ce) => ce.component_type == segment,
351            Value::ComponentObject { component_type, .. } => component_type == segment,
352            _ => false,
353        })
354        .collect::<Vec<_>>();
355    match matches.as_slice() {
356        [(index, value)] => Ok(((*value).clone(), ce_child_label(value, *index))),
357        [] => Err(format!("component-expression child not found: {segment}")),
358        _ => Err(format!(
359            "ambiguous component-expression child: {segment}; use its index"
360        )),
361    }
362}
363
364fn ce_child_label(value: &Value, index: usize) -> String {
365    match value {
366        Value::ComponentExpr(ce) => authored_ce_name(ce).unwrap_or_else(|| index.to_string()),
367        _ => index.to_string(),
368    }
369}
370
371fn authored_ce_name(ce: &MaterializedCE) -> Option<String> {
372    ce.named.iter().find_map(|(name, value)| {
373        (name == "name").then(|| match value {
374            Value::String(value) => Some(value.clone()),
375            _ => None,
376        })?
377    })
378}
379
380fn resolve_live(
381    ids: Vec<ComponentId>,
382    segment: &str,
383    world: &World,
384) -> Result<(Value, String), String> {
385    if let Ok(index) = segment.parse::<usize>() {
386        let id = ids
387            .get(index)
388            .copied()
389            .ok_or_else(|| format!("component index out of range: {index}"))?;
390        return Ok((component_value(world, id), component_label(world, id)));
391    }
392    let id = if let Ok(guid) = segment.parse::<uuid::Uuid>() {
393        ids.iter().copied().find(|id| {
394            world
395                .get_component_node(*id)
396                .is_some_and(|n| n.guid == guid)
397        })
398    } else if let Some(id) = parse_component_id_short(segment) {
399        ids.contains(&id).then_some(id)
400    } else {
401        let found = ids
402            .iter()
403            .copied()
404            .filter(|id| {
405                world
406                    .get_component_node(*id)
407                    .is_some_and(|node| node.name == segment)
408            })
409            .collect::<Vec<_>>();
410        match found.as_slice() {
411            [id] => Some(*id),
412            [] => None,
413            _ => {
414                return Err(format!(
415                    "ambiguous component name: {segment}; use index, id, or GUID"
416                ));
417            }
418        }
419    };
420    let id = id.ok_or_else(|| format!("component child not found: {segment}"))?;
421    Ok((component_value(world, id), component_label(world, id)))
422}
423
424fn ensure_navigable(value: &Value, world: &World) -> Result<(), String> {
425    match value {
426        Value::Object(id) if id.with_map(|_| ()).is_some() => Ok(()),
427        Value::Map(_) | Value::Array(_) | Value::ComponentExpr(_) => Ok(()),
428        Value::ComponentObject { id, .. } if world.get_component_node(*id).is_some() => Ok(()),
429        Value::ComponentObject { id, .. } => Err(format!(
430            "stale component handle: component {id:?} is not live"
431        )),
432        other => Err(format!(
433            "cd: expected a table, array, live component, or component expression; got {other:?}"
434        )),
435    }
436}
437
438fn component_value(world: &World, id: ComponentId) -> Value {
439    Value::ComponentObject {
440        id,
441        component_type: world.component_name(id).unwrap_or("<deleted>").to_string(),
442    }
443}
444
445fn component_label(world: &World, id: ComponentId) -> String {
446    let name = world.component_label(id).unwrap_or("");
447    if name.is_empty() {
448        format_component_id_short(id)
449    } else {
450        name.to_string()
451    }
452}
453
454fn live_pwd(world: &World, mut id: ComponentId) -> String {
455    let mut parts = Vec::new();
456    loop {
457        let Some(node) = world.get_component_node(id) else {
458            return "/".into();
459        };
460        parts.push(format!("{}:{}", format_component_id_short(id), node.name));
461        match world.parent_of(id) {
462            Some(parent) => id = parent,
463            None => break,
464        }
465    }
466    parts.reverse();
467    format!("/{}", parts.join("/"))
468}
469
470fn format_component_id_short(id: ComponentId) -> String {
471    let debug = format!("{id:?}");
472    debug
473        .split_once('(')
474        .and_then(|(_, rest)| rest.strip_suffix(')'))
475        .unwrap_or(&debug)
476        .to_string()
477}
478
479fn parse_component_id_short(value: &str) -> Option<ComponentId> {
480    let (index, version) = value.split_once('v')?;
481    let index: u32 = index.parse().ok()?;
482    let version: u32 = version.parse().ok()?;
483    Some(KeyData::from_ffi((u64::from(version) << 32) | u64::from(index)).into())
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use std::collections::HashMap;
490
491    #[test]
492    fn commands_are_classified_without_claiming_language_calls() {
493        assert_eq!(parse_repl_input("ls\n".into()), ReplInput::Ls);
494        assert_eq!(parse_repl_input("pwd".into()), ReplInput::Pwd);
495        assert_eq!(
496            parse_repl_input("cd settings.theme".into()),
497            ReplInput::Cd("settings.theme".into())
498        );
499        assert!(matches!(
500            parse_repl_input("ls()".into()),
501            ReplInput::Snippet(_)
502        ));
503        assert!(matches!(
504            parse_repl_input("let cd = 1".into()),
505            ReplInput::Snippet(_)
506        ));
507    }
508
509    #[test]
510    fn tables_and_arrays_use_breadcrumbs_and_reject_scalar_targets() {
511        let world = World::default();
512        let value = Value::Map(HashMap::from([(
513            "items".into(),
514            Value::Array(vec![Value::Map(HashMap::from([(
515                "name".into(),
516                Value::String("cat".into()),
517            )]))]),
518        )]));
519        let mut navigation = NavigationState::new();
520        navigation
521            .set_evaluated(value, Some("state".into()), &world)
522            .unwrap();
523        navigation.cd_child("items", &world).unwrap();
524        navigation.cd_child("0", &world).unwrap();
525        assert_eq!(navigation.pwd(&world), "$state/items/0");
526        assert!(navigation.cd_child("name", &world).is_err());
527        assert_eq!(navigation.pwd(&world), "$state/items/0");
528        navigation.cd_parent(&world);
529        assert_eq!(navigation.pwd(&world), "$state/items");
530    }
531}