Skip to main content

loam_app/
script.rs

1use std::path::Path;
2
3use anyhow::{anyhow, bail, Context as _, Result};
4
5use loam_console::CommandLine;
6use loam_runtime::Stores;
7
8use crate::args::Args;
9use crate::session::SessionConsole;
10
11// Captures must finish after the last scripted frame presents.
12const SETTLE_FRAMES: u64 = 60;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct ScriptStep {
16    pub frame: u64,
17    pub command: CommandLine,
18}
19
20/// Equal frame indices preserve file order.
21#[derive(Clone, Debug, Default, PartialEq, Eq)]
22pub struct Script {
23    steps: Vec<ScriptStep>,
24}
25
26impl Script {
27    /// Skips blank and `#` lines; a mid-line `#` belongs to the command.
28    pub fn parse(source: &str) -> Result<Self> {
29        let mut steps: Vec<ScriptStep> = Vec::new();
30        for (index, raw) in source.lines().enumerate() {
31            let number = index + 1;
32            let line = raw.trim();
33            if line.is_empty() || line.starts_with('#') {
34                continue;
35            }
36            let (frame_token, rest) = line
37                .split_once(char::is_whitespace)
38                .ok_or_else(|| anyhow!("line {number}: `{line}` names a frame and no command"))?;
39            let frame: u64 = frame_token
40                .parse()
41                .map_err(|e| anyhow!("line {number}: `{frame_token}` is not a frame index: {e}"))?;
42            let command = rest.trim();
43            if command.is_empty() {
44                bail!("line {number}: `{line}` names a frame and no command");
45            }
46            if let Some(previous) = steps.last() {
47                if frame < previous.frame {
48                    bail!(
49                        "line {number}: frame {frame} precedes frame {}",
50                        previous.frame
51                    );
52                }
53            }
54            steps.push(ScriptStep {
55                frame,
56                command: CommandLine::parse(command)
57                    .ok_or_else(|| anyhow!("line {number}: no command tokens"))?,
58            });
59        }
60        Ok(Self { steps })
61    }
62
63    pub fn load(path: &Path) -> Result<Self> {
64        let source = std::fs::read_to_string(path)
65            .with_context(|| format!("reading script {}", path.display()))?;
66        Self::parse(&source).with_context(|| format!("parsing script {}", path.display()))
67    }
68
69    pub fn steps(&self) -> &[ScriptStep] {
70        &self.steps
71    }
72
73    pub fn last_frame(&self) -> u64 {
74        self.steps.last().map_or(0, |step| step.frame)
75    }
76}
77
78#[derive(Copy, Clone, Debug, PartialEq, Eq)]
79pub enum ScriptStatus {
80    Running,
81    Finished,
82}
83
84#[derive(Debug)]
85pub struct ScriptDriver {
86    steps: std::iter::Peekable<std::vec::IntoIter<ScriptStep>>,
87    frame: u64,
88    exit_frame: u64,
89}
90
91impl ScriptDriver {
92    pub fn new(script: Script) -> Self {
93        let exit_frame = script.last_frame().saturating_add(SETTLE_FRAMES);
94        Self {
95            steps: script.steps.into_iter().peekable(),
96            frame: 0,
97            exit_frame,
98        }
99    }
100
101    pub fn frame(&self) -> u64 {
102        self.frame
103    }
104
105    pub fn advance_with(&mut self, mut submit: impl FnMut(CommandLine)) -> ScriptStatus {
106        while self
107            .steps
108            .peek()
109            .is_some_and(|step| step.frame <= self.frame)
110        {
111            if let Some(step) = self.steps.next() {
112                submit(step.command);
113            }
114        }
115        let status = if self.frame >= self.exit_frame {
116            ScriptStatus::Finished
117        } else {
118            ScriptStatus::Running
119        };
120        self.frame = self.frame.saturating_add(1);
121        status
122    }
123
124    pub fn advance_console<A: Stores>(&mut self, console: &mut SessionConsole<A>) -> ScriptStatus {
125        self.advance_with(|command| {
126            console.execute(&loam_console::render_line(
127                &command.name,
128                &command.arg_refs(),
129            ));
130        })
131    }
132}
133
134pub fn driver_from_args(args: &Args) -> Result<Option<ScriptDriver>> {
135    if args.has_bare_flag("script") {
136        bail!("--script needs its path attached: --script=path/to/file.script");
137    }
138    match args.get("script") {
139        Some(path) => Ok(Some(ScriptDriver::new(Script::load(Path::new(path))?))),
140        None => Ok(None),
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use loam_console::CommandQueue;
148
149    #[test]
150    fn invalid_frame_or_empty_command_is_rejected() {
151        for source in ["10", " 10  ", "-1 reset", "1.5 reset", "abc reset"] {
152            assert!(Script::parse(source).is_err(), "{source}");
153        }
154    }
155
156    #[test]
157    fn descending_frames_report_the_line() {
158        let error = Script::parse("0 reset\n10 spin\n5 hud").unwrap_err();
159        assert!(error.to_string().contains("line 3"));
160    }
161
162    #[test]
163    fn script_requires_an_attached_path() {
164        let args = Args::from_argv(["--script", "some.script"]);
165        assert!(driver_from_args(&args)
166            .unwrap_err()
167            .to_string()
168            .contains("--script="));
169    }
170
171    #[test]
172    fn commands_fire_once_in_frame_and_file_order() {
173        let mut driver = ScriptDriver::new(
174            Script::parse("0 mark boot\n2 mark second-a\n2 mark second-b\n5 mark last").unwrap(),
175        );
176        let mut queue = CommandQueue::new();
177        let mut fired = Vec::new();
178        for frame in 0..8 {
179            driver.advance_with(|command| queue.submit(command));
180            fired.extend(
181                queue
182                    .drain()
183                    .map(|command| (frame, command.args[0].clone())),
184            );
185        }
186        assert_eq!(
187            fired,
188            [
189                (0, "boot".to_string()),
190                (2, "second-a".to_string()),
191                (2, "second-b".to_string()),
192                (5, "last".to_string()),
193            ]
194        );
195    }
196
197    #[test]
198    fn finish_waits_for_capture_margin() {
199        for source in ["", "5 mark end"] {
200            let script = Script::parse(source).unwrap();
201            let finish = script.last_frame() + SETTLE_FRAMES;
202            let mut driver = ScriptDriver::new(script);
203            for _ in 0..finish {
204                assert_eq!(driver.advance_with(|_| {}), ScriptStatus::Running);
205            }
206            assert_eq!(driver.advance_with(|_| {}), ScriptStatus::Finished);
207        }
208    }
209}
210
211#[cfg(test)]
212mod session_tests {
213    use std::cell::RefCell;
214    use std::rc::Rc;
215
216    use loam_runtime::{Bindings, HostConfig};
217
218    use super::*;
219    use crate::session::SessionApp;
220
221    loam_runtime::stores! {
222        #[derive(Default)]
223        pub struct Marked {
224            hits: Value<u32>,
225        }
226    }
227
228    fn host_marking(seen: &Rc<RefCell<Vec<Vec<String>>>>) -> SessionApp<Marked> {
229        let recorded = seen.clone();
230        SessionApp::with_args(HostConfig::new("script", Bindings::new()), Args::default()).command(
231            "mark",
232            "record a marker",
233            move |args, _submit, _out| {
234                recorded
235                    .borrow_mut()
236                    .push(args.iter().map(|arg| (*arg).to_string()).collect());
237                Ok(())
238            },
239        )
240    }
241
242    #[test]
243    fn a_scripted_line_reaches_its_verb_at_its_own_frame() {
244        let seen = Rc::new(RefCell::new(Vec::new()));
245        let mut app = host_marking(&seen);
246        let mut driver =
247            ScriptDriver::new(Script::parse("0 mark boot\n2 mark second").expect("script"));
248
249        let mut dispatched = Vec::new();
250        for _ in 0..3 {
251            driver.advance_console(app.console_mut());
252            app.console_mut().dispatch_pending();
253            dispatched.push(seen.borrow().len());
254        }
255
256        assert_eq!(dispatched, [1, 1, 2]);
257        assert_eq!(*seen.borrow(), [vec!["boot"], vec!["second"]]);
258    }
259
260    #[test]
261    fn a_quoted_scripted_argument_stays_one_argument() {
262        let seen = Rc::new(RefCell::new(Vec::new()));
263        let mut app = host_marking(&seen);
264        let mut driver =
265            ScriptDriver::new(Script::parse("0 mark \"a b\" #ff8800").expect("script"));
266
267        driver.advance_console(app.console_mut());
268        app.console_mut().dispatch_pending();
269
270        assert_eq!(*seen.borrow(), [vec!["a b", "#ff8800"]]);
271    }
272}