Skip to main content

blockworx/script/
parse.rs

1//! Decode script steps from KDL nodes — the grammar level files use inside
2//! their `step {}` groups. Errors carry byte spans into the parsed source so
3//! a bad step points at the offending token.
4
5use std::ops::Range;
6use std::time::Duration;
7
8use crate::schema::kdl::{Node, Value};
9use crate::tools::names::ToolName;
10
11use super::step::{ClickCount, CueTarget, Handle, HeldKey, Step};
12
13/// A decode failure, spanned into the source the node came from.
14#[derive(Clone, Debug, PartialEq)]
15pub struct ParseError {
16    pub message: String,
17    pub span: Range<usize>,
18}
19
20impl ParseError {
21    fn new(message: impl Into<String>, span: Range<usize>) -> Self {
22        Self {
23            message: message.into(),
24            span,
25        }
26    }
27}
28
29impl std::fmt::Display for ParseError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        write!(f, "{}", self.message)
32    }
33}
34
35/// Decode one step node — `highlight`, `move-to`, `hover`, `click`,
36/// `double-click`, `drag`, `type`, or `pause`.
37pub fn step_from_node(node: &Node) -> Result<Step, ParseError> {
38    let target = |idx: usize| -> Result<CueTarget, ParseError> {
39        let entry = node.arg(idx).ok_or_else(|| {
40            ParseError::new(
41                format!("`{}` needs a target argument", node.name),
42                node.name_span.clone(),
43            )
44        })?;
45        let text = entry
46            .value
47            .as_str()
48            .ok_or_else(|| ParseError::new("a target is a quoted string", entry.span.clone()))?;
49        parse_target(text).map_err(|message| ParseError::new(message, entry.span.clone()))
50    };
51    Ok(match node.name.as_str() {
52        "highlight" => Step::Highlight {
53            target: target(0)?,
54            duration: duration_prop(node)?,
55        },
56        "move-to" => Step::MoveTo {
57            target: target(0)?,
58            duration: duration_prop(node)?,
59        },
60        "hover" => Step::Hover {
61            target: target(0)?,
62            duration: duration_prop(node)?,
63        },
64        "click" => Step::Click {
65            target: target(0)?,
66            count: ClickCount::Single,
67        },
68        "double-click" => Step::Click {
69            target: target(0)?,
70            count: ClickCount::Double,
71        },
72        "drag" => {
73            let entry = node.arg(1).ok_or_else(|| {
74                ParseError::new(
75                    "`drag` needs a destination argument",
76                    node.name_span.clone(),
77                )
78            })?;
79            let text = entry.value.as_str().ok_or_else(|| {
80                ParseError::new("a target is a quoted string", entry.span.clone())
81            })?;
82            let to = parse_drag_dest(text)
83                .map_err(|message| ParseError::new(message, entry.span.clone()))?;
84            Step::Drag {
85                from: target(0)?,
86                to,
87                duration: duration_prop(node)?,
88            }
89        }
90        "type" => Step::Type {
91            target: target(0)?,
92            text: stat(&string_prop(node, "text").ok_or_else(|| {
93                ParseError::new("`type` needs a `text` property", node.name_span.clone())
94            })?),
95            duration: duration_prop(node)?,
96        },
97        "pause" => Step::Pause {
98            duration: duration_prop(node)?,
99        },
100        "camera" => {
101            let (x, y) = (int_prop(node, "x")?, int_prop(node, "y")?);
102            let (w, h) = (int_prop(node, "w")?, int_prop(node, "h")?);
103            if w <= 0 || h <= 0 {
104                return Err(ParseError::new(
105                    "`camera` needs a positive size",
106                    node.name_span.clone(),
107                ));
108            }
109            // `secs=` glides the view from the previous framing; omitted, it cuts.
110            let duration = if node.prop("secs").is_some() {
111                duration_prop(node)?
112            } else {
113                Duration::ZERO
114            };
115            Step::Camera {
116                rect: egui::Rect::from_min_max(
117                    super::step::grid_pos(x, y),
118                    super::step::grid_pos(x + w, y + h),
119                ),
120                duration,
121            }
122        }
123        "command" => {
124            let name = node.arg(0).and_then(|e| e.value.as_str()).ok_or_else(|| {
125                ParseError::new(
126                    "`command` needs a command name as an argument",
127                    node.name_span.clone(),
128                )
129            })?;
130            Step::Command { name: stat(name) }
131        }
132        "hold" => {
133            let key = node.arg(0).and_then(|e| e.value.as_str()).ok_or_else(|| {
134                ParseError::new(
135                    "`hold` needs a key name as an argument (ctrl, shift, alt, space)",
136                    node.name_span.clone(),
137                )
138            })?;
139            Step::Hold {
140                key: Some(HeldKey::parse(key).ok_or_else(|| {
141                    ParseError::new(
142                        format!("unknown key `{key}` (ctrl, shift, alt, space)"),
143                        node.name_span.clone(),
144                    )
145                })?),
146            }
147        }
148        "release" => Step::Hold { key: None },
149        "instruct" => {
150            let text = node.arg(0).and_then(|e| e.value.as_str()).ok_or_else(|| {
151                ParseError::new(
152                    "`instruct` needs its text as an argument",
153                    node.name_span.clone(),
154                )
155            })?;
156            Step::Instruct { text: stat(text) }
157        }
158        other => {
159            return Err(ParseError::new(
160                format!("unknown script step `{other}`"),
161                node.name_span.clone(),
162            ));
163        }
164    })
165}
166
167fn int_prop(node: &Node, key: &str) -> Result<i32, ParseError> {
168    match node.prop(key) {
169        Some(prop) => match &prop.value.value {
170            Value::Int(i) => i32::try_from(*i).map_err(|_| {
171                ParseError::new(format!("`{key}` out of range"), prop.value.span.clone())
172            }),
173            _ => Err(ParseError::new(
174                format!("`{key}` needs an integer"),
175                prop.value.span.clone(),
176            )),
177        },
178        None => Err(ParseError::new(
179            format!("`{}` needs integer `{key}`", node.name),
180            node.name_span.clone(),
181        )),
182    }
183}
184
185/// Cue-target strings: `"x,y"` (grid cells), `"tool:<name>"`,
186/// `"block:<title>"`, `"corner:<title>:<lt|rt|lb|rb>"`, or logical ids —
187/// `"b4"` (a block's center) and `"b4:p3"` (a pin's wire anchor).
188fn parse_target(s: &str) -> Result<CueTarget, String> {
189    if let Some(name) = s.strip_prefix("tool:") {
190        return Ok(CueTarget::ToolButton(parse_tool(name)?));
191    }
192    if let Some(title) = s.strip_prefix("block:") {
193        return Ok(CueTarget::Block(stat(title)));
194    }
195    if let Some(rest) = s.strip_prefix("corner:") {
196        let (title, handle) = rest
197            .rsplit_once(':')
198            .ok_or_else(|| format!("corner target `{s}` needs `corner:<title>:<handle>`"))?;
199        return Ok(CueTarget::Corner(stat(title), parse_handle(handle)?));
200    }
201    if let Some((x, y)) = s.split_once(',') {
202        let parse = |v: &str| {
203            v.trim()
204                .parse::<i32>()
205                .map_err(|_| format!("bad target `{s}`"))
206        };
207        return Ok(CueTarget::World(super::step::grid_pos(
208            parse(x)?,
209            parse(y)?,
210        )));
211    }
212    // Logical ids: `b4` or `b4:p3`, in the ids the document KDL uses.
213    let ident_ok = |v: &str| !v.is_empty() && v.chars().all(|c| c.is_alphanumeric() || c == '_');
214    if let Some((block, pin)) = s.split_once(':') {
215        if ident_ok(block) && ident_ok(pin) {
216            return Ok(CueTarget::PinAnchor {
217                block: stat(block),
218                pin: stat(pin),
219            });
220        }
221        return Err(format!("bad target `{s}`"));
222    }
223    if ident_ok(s) {
224        return Ok(CueTarget::BlockId(stat(s)));
225    }
226    Err(format!("bad target `{s}`"))
227}
228
229/// A drag destination: any cue target, or a cell offset relative to the
230/// drag's start — spelled as a cell pair whose x component carries an
231/// explicit sign (`"+4,-3"`, `"-2,0"`). Only `drag` reads the sign this way;
232/// everywhere else a signed pair is an absolute (possibly negative) cell.
233fn parse_drag_dest(s: &str) -> Result<CueTarget, String> {
234    if let Some((x, y)) = s.split_once(',')
235        && matches!(x.trim().as_bytes().first(), Some(b'+' | b'-'))
236    {
237        let parse = |v: &str| {
238            v.trim()
239                .parse::<i32>()
240                .map_err(|_| format!("bad target `{s}`"))
241        };
242        return Ok(CueTarget::Relative {
243            dx: parse(x)?,
244            dy: parse(y)?,
245        });
246    }
247    parse_target(s)
248}
249
250fn parse_handle(s: &str) -> Result<Handle, String> {
251    Ok(match s {
252        "lt" => Handle::LeftTop,
253        "rt" => Handle::RightTop,
254        "lb" => Handle::LeftBottom,
255        "rb" => Handle::RightBottom,
256        other => return Err(format!("unknown corner handle `{other}`")),
257    })
258}
259
260/// The tools a script can name — the toolbar set, in their KDL spellings.
261fn parse_tool(s: &str) -> Result<ToolName, String> {
262    Ok(match s {
263        "select" => ToolName::Select,
264        "new-block" => ToolName::NewBlock,
265        // `comment` is the pre-rename spelling, still accepted for old scripts.
266        "area" | "comment" => ToolName::NewArea,
267        "add-port" => ToolName::AddPort,
268        "add-image" => ToolName::NewImage,
269        "add-text" => ToolName::AddText,
270        "route" => ToolName::Route,
271        other => return Err(format!("unknown tool `{other}`")),
272    })
273}
274
275/// The inverse of `parse_tool`: a toolbar tool's KDL spelling, `None` for
276/// tools scripts can't name.
277pub fn tool_kdl_name(name: ToolName) -> Option<&'static str> {
278    Some(match name {
279        ToolName::Select => "select",
280        ToolName::NewBlock => "new-block",
281        ToolName::NewArea => "area",
282        ToolName::AddPort => "add-port",
283        ToolName::NewImage => "add-image",
284        ToolName::AddText => "add-text",
285        ToolName::Route => "route",
286        _ => return None,
287    })
288}
289
290/// The KDL spelling of a corner handle, the inverse of [`parse_handle`].
291pub fn handle_kdl_name(handle: Handle) -> &'static str {
292    match handle {
293        Handle::LeftTop => "lt",
294        Handle::RightTop => "rt",
295        Handle::LeftBottom => "lb",
296        Handle::RightBottom => "rb",
297    }
298}
299
300/// Every script in the registry parses once per process, so leaking its handful
301/// of strings keeps [`Step`]/[`CueTarget`] `Copy` over `&'static str`.
302pub(crate) fn stat(s: &str) -> &'static str {
303    Box::leak(s.to_owned().into_boxed_str())
304}
305
306pub(crate) fn string_prop(node: &Node, key: &str) -> Option<String> {
307    node.prop(key)?.value.value.as_str().map(str::to_owned)
308}
309
310/// The `secs=` property as a [`Duration`] — the type makes a negative or
311/// non-finite duration unrepresentable, so it is rejected here.
312fn duration_prop(node: &Node) -> Result<Duration, ParseError> {
313    let Some(prop) = node.prop("secs") else {
314        return Err(ParseError::new(
315            format!("`{}` needs a `secs` property", node.name),
316            node.name_span.clone(),
317        ));
318    };
319    let secs = match &prop.value.value {
320        Value::Float(f) => *f,
321        Value::Int(i) => *i as f64,
322        _ => {
323            return Err(ParseError::new(
324                "`secs` needs a number",
325                prop.value.span.clone(),
326            ));
327        }
328    };
329    if !(secs >= 0.0 && secs.is_finite()) {
330        return Err(ParseError::new(
331            "`secs` must be a non-negative number",
332            prop.value.span.clone(),
333        ));
334    }
335    Ok(Duration::from_secs_f64(secs))
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::script::step::grid_pos;
342
343    fn decode(src: &str) -> Result<Step, ParseError> {
344        let nodes = crate::schema::kdl::parse(src).unwrap();
345        step_from_node(&nodes[0])
346    }
347
348    #[test]
349    fn command_decodes_its_name() {
350        assert_eq!(
351            decode("command \"lock\"").unwrap(),
352            Step::Command { name: "lock" }
353        );
354        assert!(decode("command").is_err());
355    }
356
357    #[test]
358    fn hold_and_release_decode_the_cue_key() {
359        assert_eq!(
360            decode("hold \"ctrl\"").unwrap(),
361            Step::Hold {
362                key: Some(HeldKey::Ctrl)
363            }
364        );
365        assert_eq!(decode("release").unwrap(), Step::Hold { key: None });
366        assert!(
367            decode("hold \"meta\"").is_err(),
368            "unknown key names are rejected"
369        );
370        assert!(decode("hold").is_err());
371    }
372
373    #[test]
374    fn camera_secs_glides_and_defaults_to_a_cut() {
375        assert_eq!(
376            decode("camera x=0 y=0 w=10 h=10").unwrap(),
377            Step::Camera {
378                rect: egui::Rect::from_min_max(grid_pos(0, 0), grid_pos(10, 10)),
379                duration: Duration::ZERO,
380            }
381        );
382        assert_eq!(
383            decode("camera x=0 y=0 w=10 h=10 secs=1.5").unwrap(),
384            Step::Camera {
385                rect: egui::Rect::from_min_max(grid_pos(0, 0), grid_pos(10, 10)),
386                duration: Duration::from_millis(1500),
387            }
388        );
389    }
390
391    #[test]
392    fn steps_decode_from_nodes() {
393        assert_eq!(
394            decode("click \"8,6\"").unwrap(),
395            Step::Click {
396                target: CueTarget::World(grid_pos(8, 6)),
397                count: ClickCount::Single,
398            }
399        );
400    }
401
402    #[test]
403    fn target_strings_cover_every_form() {
404        assert_eq!(
405            parse_target("8,6").unwrap(),
406            CueTarget::World(grid_pos(8, 6))
407        );
408        assert_eq!(
409            parse_target("tool:route").unwrap(),
410            CueTarget::ToolButton(ToolName::Route)
411        );
412        assert_eq!(
413            parse_target("tool:area").unwrap(),
414            CueTarget::ToolButton(ToolName::NewArea)
415        );
416        assert_eq!(
417            parse_target("tool:comment").unwrap(),
418            CueTarget::ToolButton(ToolName::NewArea),
419            "the pre-rename spelling still names the area tool",
420        );
421        assert_eq!(
422            parse_target("block:core").unwrap(),
423            CueTarget::Block("core")
424        );
425        assert_eq!(
426            parse_target("corner:core:rb").unwrap(),
427            CueTarget::Corner("core", Handle::RightBottom)
428        );
429        // Logical ids: a bare block id, and block:pin for a wire anchor.
430        assert_eq!(parse_target("b4").unwrap(), CueTarget::BlockId("b4"));
431        assert_eq!(
432            parse_target("b4:p3").unwrap(),
433            CueTarget::PinAnchor {
434                block: "b4",
435                pin: "p3"
436            }
437        );
438        assert!(parse_target("corner:core:xx").is_err());
439        assert!(parse_target("b4:").is_err());
440        assert!(parse_target("not an id").is_err());
441        assert!(parse_target("8,x").is_err());
442    }
443
444    #[test]
445    fn drag_destinations_with_a_signed_x_are_relative() {
446        assert_eq!(
447            parse_drag_dest("+4,-3").unwrap(),
448            CueTarget::Relative { dx: 4, dy: -3 }
449        );
450        assert_eq!(
451            parse_drag_dest("-2,+3").unwrap(),
452            CueTarget::Relative { dx: -2, dy: 3 }
453        );
454        // An unsigned x keeps the pair an absolute cell, and every other
455        // target form passes straight through.
456        assert_eq!(
457            parse_drag_dest("4,-3").unwrap(),
458            CueTarget::World(grid_pos(4, -3))
459        );
460        assert_eq!(parse_drag_dest("b4").unwrap(), CueTarget::BlockId("b4"));
461        assert!(parse_drag_dest("+4,x").is_err());
462    }
463
464    /// The spans errors carry must land on the offending token, not the
465    /// whole node — that's what an error report points at.
466    #[test]
467    fn errors_span_the_offending_token() {
468        // The bad target string: span covers the quoted argument.
469        let src = "click \"not an id\"";
470        let err = decode(src).unwrap_err();
471        assert_eq!(&src[err.span.clone()], "\"not an id\"");
472
473        // A bad secs value: span covers the value.
474        let src = "pause secs=\"soon\"";
475        let err = decode(src).unwrap_err();
476        assert_eq!(&src[err.span.clone()], "\"soon\"");
477
478        // A missing property: span covers the node name.
479        let src = "move-to \"3,4\"";
480        let err = decode(src).unwrap_err();
481        assert_eq!(&src[err.span.clone()], "move-to");
482
483        // An unknown step: span covers the name.
484        let src = "wiggle \"3,4\"";
485        let err = decode(src).unwrap_err();
486        assert_eq!(&src[err.span.clone()], "wiggle");
487    }
488}