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, 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}
37
38/// Parse one `verb:argument` argument into a step.
39///
40/// The argument half is taken verbatim after the first colon, so
41/// `type:https://example.com` and `type:a:b` work without escaping — a
42/// rule worth keeping, since text is the argument most likely to contain
43/// a colon.
44pub fn parse(argument: &str) -> Result<Step, VerbError> {
45    let Some((verb, rest)) = argument.split_once(':') else {
46        return Err(VerbError::Malformed(argument.to_string()));
47    };
48    let verb = verb.trim();
49
50    let step = match verb {
51        "click" => Step::Click {
52            target: label(verb, rest)?,
53        },
54        "double" => Step::DoubleClick {
55            target: label(verb, rest)?,
56        },
57        "verify" => Step::Verify {
58            target: label(verb, rest)?,
59        },
60        "wait" => Step::WaitFor {
61            target: label(verb, rest)?,
62        },
63        "gone" => Step::WaitGone {
64            target: label(verb, rest)?,
65        },
66        "type" => Step::Type {
67            text: rest.to_string(),
68        },
69        "key" => Step::Key {
70            chord: label(verb, rest)?,
71        },
72        "drag" => {
73            let Some((from, to)) = rest.split_once('>') else {
74                return Err(VerbError::DragShape(rest.to_string()));
75            };
76            if from.trim().is_empty() || to.trim().is_empty() {
77                return Err(VerbError::DragShape(rest.to_string()));
78            }
79            Step::Drag {
80                from: from.trim().to_string(),
81                to: to.trim().to_string(),
82            }
83        }
84        "scroll" => scroll(verb, rest, Axis::Vertical)?,
85        "hscroll" => scroll(verb, rest, Axis::Horizontal)?,
86        "pause" => {
87            let ms = rest
88                .trim()
89                .parse::<u64>()
90                .map_err(|_| VerbError::PauseValue(rest.to_string()))?;
91            Step::Pause { ms }
92        }
93        other => return Err(VerbError::Unknown(other.to_string())),
94    };
95    Ok(step)
96}
97
98/// `LABEL>AMOUNT` for a scroll, on the axis the verb chose.
99///
100/// The amount is required rather than defaulted: it is already the least
101/// predictable value in the tool, and silently picking one for the
102/// caller would make an unpredictable thing invisible too.
103fn scroll(verb: &str, rest: &str, axis: Axis) -> Result<Step, VerbError> {
104    let shape = || VerbError::ScrollShape(verb.to_string(), rest.to_string());
105    let Some((target, amount)) = rest.split_once('>') else {
106        return Err(shape());
107    };
108    let target = target.trim();
109    if target.is_empty() {
110        return Err(shape());
111    }
112    let amount: i32 = amount.trim().parse().map_err(|_| shape())?;
113    if amount == 0 {
114        return Err(shape());
115    }
116    Ok(Step::Scroll {
117        target: target.to_string(),
118        amount,
119        axis,
120    })
121}
122
123/// Parse every argument, or fail on the first bad one.
124///
125/// All-or-nothing by design: a chain with a typo in step 7 must not
126/// perform steps 1 through 6 first.
127pub fn parse_all<I, S>(arguments: I) -> Result<Vec<Step>, VerbError>
128where
129    I: IntoIterator<Item = S>,
130    S: AsRef<str>,
131{
132    arguments.into_iter().map(|a| parse(a.as_ref())).collect()
133}
134
135fn label(verb: &str, rest: &str) -> Result<String, VerbError> {
136    let trimmed = rest.trim();
137    if trimmed.is_empty() {
138        return Err(VerbError::EmptyLabel(verb.to_string()));
139    }
140    Ok(trimmed.to_string())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn scroll_chains_as_label_then_amount() {
149        assert_eq!(
150            parse("scroll:results>-3").expect("valid"),
151            Step::Scroll {
152                target: "results".into(),
153                amount: -3,
154                axis: crate::flow::Axis::Vertical,
155            }
156        );
157        assert_eq!(
158            parse("hscroll:timeline>5").expect("valid"),
159            Step::Scroll {
160                target: "timeline".into(),
161                amount: 5,
162                axis: crate::flow::Axis::Horizontal,
163            }
164        );
165    }
166
167    #[test]
168    fn a_scroll_without_an_amount_is_refused() {
169        for bad in [
170            "scroll:results",
171            "scroll:>3",
172            "scroll:results>",
173            "scroll:results>lots",
174        ] {
175            assert!(parse(bad).is_err(), "should refuse {bad:?}");
176        }
177    }
178
179    #[test]
180    fn a_scroll_of_zero_is_refused_rather_than_silently_doing_nothing() {
181        assert!(parse("scroll:results>0").is_err());
182    }
183
184    #[test]
185    fn every_verb_maps_to_its_flow_action() {
186        assert_eq!(
187            parse("click:submit"),
188            Ok(Step::Click {
189                target: "submit".into()
190            })
191        );
192        assert_eq!(
193            parse("double:icon"),
194            Ok(Step::DoubleClick {
195                target: "icon".into()
196            })
197        );
198        assert_eq!(
199            parse("verify:done"),
200            Ok(Step::Verify {
201                target: "done".into()
202            })
203        );
204        assert_eq!(
205            parse("wait:dialog"),
206            Ok(Step::WaitFor {
207                target: "dialog".into()
208            })
209        );
210        assert_eq!(
211            parse("gone:spinner"),
212            Ok(Step::WaitGone {
213                target: "spinner".into()
214            })
215        );
216        assert_eq!(
217            parse("key:cmd+s"),
218            Ok(Step::Key {
219                chord: "cmd+s".into()
220            })
221        );
222        assert_eq!(parse("pause:250"), Ok(Step::Pause { ms: 250 }));
223        assert_eq!(
224            parse("drag:handle>zone"),
225            Ok(Step::Drag {
226                from: "handle".into(),
227                to: "zone".into()
228            })
229        );
230    }
231
232    #[test]
233    fn type_keeps_everything_after_the_first_colon() {
234        // Text is the argument most likely to contain a colon, so it must
235        // not need escaping.
236        assert_eq!(
237            parse("type:https://example.com"),
238            Ok(Step::Type {
239                text: "https://example.com".into()
240            })
241        );
242        assert_eq!(
243            parse("type:a:b:c"),
244            Ok(Step::Type {
245                text: "a:b:c".into()
246            })
247        );
248    }
249
250    #[test]
251    fn type_may_be_deliberately_empty() {
252        // Clearing a field by typing nothing is meaningless, but so is
253        // rejecting it — an empty string is a valid thing to type.
254        assert_eq!(
255            parse("type:"),
256            Ok(Step::Type {
257                text: String::new()
258            })
259        );
260    }
261
262    #[test]
263    fn an_argument_without_a_colon_is_malformed() {
264        assert!(matches!(parse("click"), Err(VerbError::Malformed(_))));
265    }
266
267    #[test]
268    fn an_unknown_verb_names_the_real_ones() {
269        let error = parse("teleport:home").expect_err("unknown");
270        let message = error.to_string();
271        assert!(message.contains("click"), "lists the options: {message}");
272    }
273
274    #[test]
275    fn a_label_verb_without_a_label_is_refused() {
276        assert!(matches!(parse("click:"), Err(VerbError::EmptyLabel(_))));
277        assert!(matches!(parse("wait:   "), Err(VerbError::EmptyLabel(_))));
278    }
279
280    #[test]
281    fn drag_requires_both_ends() {
282        assert!(matches!(parse("drag:handle"), Err(VerbError::DragShape(_))));
283        assert!(matches!(parse("drag:>zone"), Err(VerbError::DragShape(_))));
284        assert!(matches!(
285            parse("drag:handle>"),
286            Err(VerbError::DragShape(_))
287        ));
288    }
289
290    #[test]
291    fn pause_needs_a_number() {
292        assert!(matches!(parse("pause:soon"), Err(VerbError::PauseValue(_))));
293        assert!(matches!(parse("pause:-5"), Err(VerbError::PauseValue(_))));
294    }
295
296    #[test]
297    fn whitespace_around_labels_is_tolerated() {
298        assert_eq!(
299            parse("click: submit "),
300            Ok(Step::Click {
301                target: "submit".into()
302            })
303        );
304    }
305
306    #[test]
307    fn a_chain_fails_whole_rather_than_running_the_good_part() {
308        // The important property: a typo in the last argument must not
309        // perform the first three actions.
310        let result = parse_all(["click:a", "type:hi", "key:cmd+s", "clik:b"]);
311        assert!(matches!(result, Err(VerbError::Unknown(_))));
312    }
313
314    #[test]
315    fn a_whole_chain_parses_in_order() {
316        let steps = parse_all(["click:submit", "type:hello", "wait:done"]).expect("valid");
317        assert_eq!(steps.len(), 3);
318        assert_eq!(
319            steps[2],
320            Step::WaitFor {
321                target: "done".into()
322            }
323        );
324    }
325}