Skip to main content

pixelactions_core/
verb.rs

1//! `verb:argument` — the chained-argv form of a step.
2//!
3//! One invocation performing many actions is the cheapest possible
4//! programmability: no protocol, no daemon, nothing to install. It is
5//! what `cliclick` and `xdotool` have always done, and it collapses N
6//! process spawns into one — which matters less for the spawn cost
7//! (~3ms) than for doing a single relocation pass instead of N.
8//!
9//! The verbs deliberately mirror the flow file's actions one-for-one, so
10//! learning either teaches the other. That rule is borrowed from tmux's
11//! control mode: the protocol's verbs *are* the command set.
12
13use crate::flow::{Axis, Step};
14
15/// Why a chained argument could not be read as a step.
16#[derive(Debug, thiserror::Error, PartialEq)]
17pub enum VerbError {
18    #[error("{0:?} is not verb:argument — try click:submit, type:\"hello\", or wait:done")]
19    Malformed(String),
20    #[error(
21        "unknown verb {0:?} — expected click, double, drag, scroll, hscroll, type, key, \
22         verify, changed, wait, gone, or pause"
23    )]
24    Unknown(String),
25    #[error("drag needs from>to, e.g. drag:handle>dropzone (got {0:?})")]
26    DragShape(String),
27    #[error("pause needs milliseconds, e.g. pause:250 (got {0:?})")]
28    PauseValue(String),
29    #[error(
30        "{0} needs label>amount, e.g. {0}:results>3 to go one way and {0}:results>-3 the \
31         other (got {1:?})"
32    )]
33    ScrollShape(String, String),
34    #[error("{0} needs a label, e.g. {0}:submit")]
35    EmptyLabel(String),
36    #[error(
37        "changed takes a label, optionally with a percentage: changed:panel, or \
38         changed:panel>2.5 to require more than 2.5% of its pixels to differ (got {0:?})"
39    )]
40    ChangedShape(String),
41}
42
43/// Parse one `verb:argument` argument into a step.
44///
45/// The argument half is taken verbatim after the first colon, so
46/// `type:https://example.com` and `type:a:b` work without escaping — a
47/// rule worth keeping, since text is the argument most likely to contain
48/// a colon.
49pub fn parse(argument: &str) -> Result<Step, VerbError> {
50    let Some((verb, rest)) = argument.split_once(':') else {
51        return Err(VerbError::Malformed(argument.to_string()));
52    };
53    let verb = verb.trim();
54
55    let step = match verb {
56        "click" => Step::Click {
57            target: label(verb, rest)?,
58        },
59        "double" => Step::DoubleClick {
60            target: label(verb, rest)?,
61        },
62        "verify" => Step::Verify {
63            target: label(verb, rest)?,
64        },
65        "wait" => Step::WaitFor {
66            target: label(verb, rest)?,
67        },
68        "gone" => Step::WaitGone {
69            target: label(verb, rest)?,
70        },
71        "type" => Step::Type {
72            text: rest.to_string(),
73        },
74        "key" => Step::Key {
75            chord: label(verb, rest)?,
76        },
77        "drag" => {
78            let Some((from, to)) = rest.split_once('>') else {
79                return Err(VerbError::DragShape(rest.to_string()));
80            };
81            if from.trim().is_empty() || to.trim().is_empty() {
82                return Err(VerbError::DragShape(rest.to_string()));
83            }
84            Step::Drag {
85                from: from.trim().to_string(),
86                to: to.trim().to_string(),
87            }
88        }
89        "changed" => {
90            // `changed:label` or `changed:label>PCT`. The threshold is
91            // optional where scroll's amount is required, because zero is
92            // a meaningful default here and an unpredictable one there.
93            let (target, tolerance) = match rest.split_once('>') {
94                None => (rest, 0.0),
95                Some((target, pct)) => {
96                    let parsed: f64 = pct
97                        .trim()
98                        .parse()
99                        .map_err(|_| VerbError::ChangedShape(rest.to_string()))?;
100                    if !parsed.is_finite() || !(0.0..=100.0).contains(&parsed) {
101                        return Err(VerbError::ChangedShape(rest.to_string()));
102                    }
103                    (target, parsed)
104                }
105            };
106            Step::Changed {
107                target: label(verb, target)?,
108                tolerance,
109            }
110        }
111        "scroll" => scroll(verb, rest, Axis::Vertical)?,
112        "hscroll" => scroll(verb, rest, Axis::Horizontal)?,
113        "pause" => {
114            let ms = rest
115                .trim()
116                .parse::<u64>()
117                .map_err(|_| VerbError::PauseValue(rest.to_string()))?;
118            Step::Pause { ms }
119        }
120        other => return Err(VerbError::Unknown(other.to_string())),
121    };
122    Ok(step)
123}
124
125/// `LABEL>AMOUNT` for a scroll, on the axis the verb chose.
126///
127/// The amount is required rather than defaulted: it is already the least
128/// predictable value in the tool, and silently picking one for the
129/// caller would make an unpredictable thing invisible too.
130fn scroll(verb: &str, rest: &str, axis: Axis) -> Result<Step, VerbError> {
131    let shape = || VerbError::ScrollShape(verb.to_string(), rest.to_string());
132    let Some((target, amount)) = rest.split_once('>') else {
133        return Err(shape());
134    };
135    let target = target.trim();
136    if target.is_empty() {
137        return Err(shape());
138    }
139    let amount: i32 = amount.trim().parse().map_err(|_| shape())?;
140    if amount == 0 {
141        return Err(shape());
142    }
143    Ok(Step::Scroll {
144        target: target.to_string(),
145        amount,
146        axis,
147    })
148}
149
150/// Parse every argument, or fail on the first bad one.
151///
152/// All-or-nothing by design: a chain with a typo in step 7 must not
153/// perform steps 1 through 6 first.
154pub fn parse_all<I, S>(arguments: I) -> Result<Vec<Step>, VerbError>
155where
156    I: IntoIterator<Item = S>,
157    S: AsRef<str>,
158{
159    arguments.into_iter().map(|a| parse(a.as_ref())).collect()
160}
161
162fn label(verb: &str, rest: &str) -> Result<String, VerbError> {
163    let trimmed = rest.trim();
164    if trimmed.is_empty() {
165        return Err(VerbError::EmptyLabel(verb.to_string()));
166    }
167    Ok(trimmed.to_string())
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn scroll_chains_as_label_then_amount() {
176        assert_eq!(
177            parse("scroll:results>-3").expect("valid"),
178            Step::Scroll {
179                target: "results".into(),
180                amount: -3,
181                axis: crate::flow::Axis::Vertical,
182            }
183        );
184        assert_eq!(
185            parse("hscroll:timeline>5").expect("valid"),
186            Step::Scroll {
187                target: "timeline".into(),
188                amount: 5,
189                axis: crate::flow::Axis::Horizontal,
190            }
191        );
192    }
193
194    #[test]
195    fn a_scroll_without_an_amount_is_refused() {
196        for bad in [
197            "scroll:results",
198            "scroll:>3",
199            "scroll:results>",
200            "scroll:results>lots",
201        ] {
202            assert!(parse(bad).is_err(), "should refuse {bad:?}");
203        }
204    }
205
206    #[test]
207    fn a_scroll_of_zero_is_refused_rather_than_silently_doing_nothing() {
208        assert!(parse("scroll:results>0").is_err());
209    }
210
211    #[test]
212    fn every_verb_maps_to_its_flow_action() {
213        assert_eq!(
214            parse("click:submit"),
215            Ok(Step::Click {
216                target: "submit".into()
217            })
218        );
219        assert_eq!(
220            parse("double:icon"),
221            Ok(Step::DoubleClick {
222                target: "icon".into()
223            })
224        );
225        assert_eq!(
226            parse("verify:done"),
227            Ok(Step::Verify {
228                target: "done".into()
229            })
230        );
231        assert_eq!(
232            parse("wait:dialog"),
233            Ok(Step::WaitFor {
234                target: "dialog".into()
235            })
236        );
237        assert_eq!(
238            parse("gone:spinner"),
239            Ok(Step::WaitGone {
240                target: "spinner".into()
241            })
242        );
243        assert_eq!(
244            parse("key:cmd+s"),
245            Ok(Step::Key {
246                chord: "cmd+s".into()
247            })
248        );
249        assert_eq!(parse("pause:250"), Ok(Step::Pause { ms: 250 }));
250        assert_eq!(
251            parse("drag:handle>zone"),
252            Ok(Step::Drag {
253                from: "handle".into(),
254                to: "zone".into()
255            })
256        );
257    }
258
259    #[test]
260    fn type_keeps_everything_after_the_first_colon() {
261        // Text is the argument most likely to contain a colon, so it must
262        // not need escaping.
263        assert_eq!(
264            parse("type:https://example.com"),
265            Ok(Step::Type {
266                text: "https://example.com".into()
267            })
268        );
269        assert_eq!(
270            parse("type:a:b:c"),
271            Ok(Step::Type {
272                text: "a:b:c".into()
273            })
274        );
275    }
276
277    #[test]
278    fn type_may_be_deliberately_empty() {
279        // Clearing a field by typing nothing is meaningless, but so is
280        // rejecting it — an empty string is a valid thing to type.
281        assert_eq!(
282            parse("type:"),
283            Ok(Step::Type {
284                text: String::new()
285            })
286        );
287    }
288
289    #[test]
290    fn an_argument_without_a_colon_is_malformed() {
291        assert!(matches!(parse("click"), Err(VerbError::Malformed(_))));
292    }
293
294    #[test]
295    fn an_unknown_verb_names_the_real_ones() {
296        let error = parse("teleport:home").expect_err("unknown");
297        let message = error.to_string();
298        assert!(message.contains("click"), "lists the options: {message}");
299    }
300
301    #[test]
302    fn a_label_verb_without_a_label_is_refused() {
303        assert!(matches!(parse("click:"), Err(VerbError::EmptyLabel(_))));
304        assert!(matches!(parse("wait:   "), Err(VerbError::EmptyLabel(_))));
305    }
306
307    #[test]
308    fn drag_requires_both_ends() {
309        assert!(matches!(parse("drag:handle"), Err(VerbError::DragShape(_))));
310        assert!(matches!(parse("drag:>zone"), Err(VerbError::DragShape(_))));
311        assert!(matches!(
312            parse("drag:handle>"),
313            Err(VerbError::DragShape(_))
314        ));
315    }
316
317    #[test]
318    fn pause_needs_a_number() {
319        assert!(matches!(parse("pause:soon"), Err(VerbError::PauseValue(_))));
320        assert!(matches!(parse("pause:-5"), Err(VerbError::PauseValue(_))));
321    }
322
323    #[test]
324    fn whitespace_around_labels_is_tolerated() {
325        assert_eq!(
326            parse("click: submit "),
327            Ok(Step::Click {
328                target: "submit".into()
329            })
330        );
331    }
332
333    #[test]
334    fn a_chain_fails_whole_rather_than_running_the_good_part() {
335        // The important property: a typo in the last argument must not
336        // perform the first three actions.
337        let result = parse_all(["click:a", "type:hi", "key:cmd+s", "clik:b"]);
338        assert!(matches!(result, Err(VerbError::Unknown(_))));
339    }
340
341    #[test]
342    fn a_whole_chain_parses_in_order() {
343        let steps = parse_all(["click:submit", "type:hello", "wait:done"]).expect("valid");
344        assert_eq!(steps.len(), 3);
345        assert_eq!(
346            steps[2],
347            Step::WaitFor {
348                target: "done".into()
349            }
350        );
351    }
352
353    #[test]
354    fn changed_takes_a_bare_label_and_defaults_to_any_pixel() {
355        let Step::Changed { target, tolerance } = parse("changed:panel").expect("parses") else {
356            panic!("wrong step");
357        };
358        assert_eq!(target, "panel");
359        assert!((tolerance - 0.0).abs() < f64::EPSILON);
360    }
361
362    #[test]
363    fn changed_takes_a_percentage_after_the_label() {
364        let Step::Changed { target, tolerance } = parse("changed:panel>2.5").expect("parses")
365        else {
366            panic!("wrong step");
367        };
368        assert_eq!(target, "panel");
369        assert!((tolerance - 2.5).abs() < f64::EPSILON);
370    }
371
372    #[test]
373    fn changed_refuses_a_percentage_that_is_not_one() {
374        for bad in [
375            "changed:panel>",
376            "changed:panel>banana",
377            "changed:panel>-1",
378            "changed:panel>101",
379            "changed:panel>NaN",
380        ] {
381            assert!(
382                matches!(parse(bad), Err(VerbError::ChangedShape(_))),
383                "should refuse {bad:?}"
384            );
385        }
386    }
387
388    #[test]
389    fn changed_still_needs_a_label() {
390        assert!(matches!(parse("changed:"), Err(VerbError::EmptyLabel(_))));
391    }
392}