1use 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#[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
35pub 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 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
185fn 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 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
229fn 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
260fn parse_tool(s: &str) -> Result<ToolName, String> {
262 Ok(match s {
263 "select" => ToolName::Select,
264 "new-block" => ToolName::NewBlock,
265 "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
275pub 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
290pub 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
300pub(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
310fn 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 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 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 #[test]
467 fn errors_span_the_offending_token() {
468 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 let src = "pause secs=\"soon\"";
475 let err = decode(src).unwrap_err();
476 assert_eq!(&src[err.span.clone()], "\"soon\"");
477
478 let src = "move-to \"3,4\"";
480 let err = decode(src).unwrap_err();
481 assert_eq!(&src[err.span.clone()], "move-to");
482
483 let src = "wiggle \"3,4\"";
485 let err = decode(src).unwrap_err();
486 assert_eq!(&src[err.span.clone()], "wiggle");
487 }
488}