anticipate_core/
interpreter.rs

1use crate::{
2    resolve_path, Error, Instruction, Instructions, Result, ScriptParser,
3};
4use anticipate::{
5    repl::ReplSession,
6    session::{log, tee, Session},
7    ControlCode, Expect, Regex,
8};
9use ouroboros::self_referencing;
10use probability::prelude::*;
11use std::io::{BufRead, Write};
12use std::{
13    path::{Path, PathBuf},
14    thread::sleep,
15    time::Duration,
16};
17use tracing::{span, Level};
18use unicode_segmentation::UnicodeSegmentation;
19
20const PROMPT: &str = "➜ ";
21
22struct Source<T>(T);
23
24impl<T: rand::RngCore> source::Source for Source<T> {
25    fn read_u64(&mut self) -> u64 {
26        self.0.next_u64()
27    }
28}
29
30/// Options for asciinema execution.
31#[derive(Debug, Clone)]
32pub struct CinemaOptions {
33    /// Delay in milliseconds.
34    pub delay: u64,
35    /// Type pragma command.
36    pub type_pragma: bool,
37    /// Deviation for gaussian delay modification.
38    pub deviation: f64,
39    /// Shell to run.
40    pub shell: String,
41    /// Terminal columns.
42    pub cols: u64,
43    /// Terminal rows.
44    pub rows: u64,
45}
46
47impl Default for CinemaOptions {
48    fn default() -> Self {
49        Self {
50            delay: 75,
51            type_pragma: false,
52            deviation: 15.0,
53            shell: "sh -noprofile -norc".to_string(),
54            cols: 80,
55            rows: 24,
56        }
57    }
58}
59
60/// Options for the interpreter.
61pub struct InterpreterOptions {
62    /// Command to execute in the pty.
63    pub command: String,
64    /// Timeout for rexpect.
65    pub timeout: Option<u64>,
66    /// Options for asciinema.
67    pub cinema: Option<CinemaOptions>,
68    /// Identifier.
69    pub id: Option<String>,
70    /// Prompt.
71    pub prompt: Option<String>,
72    /// Echo to stdout.
73    pub echo: bool,
74    /// Format IO logged to stdout.
75    pub format: bool,
76    /// Print comments.
77    pub print_comments: bool,
78}
79
80impl Default for InterpreterOptions {
81    fn default() -> Self {
82        Self {
83            command: "sh -noprofile -norc".to_owned(),
84            prompt: None,
85            timeout: Some(5000),
86            cinema: None,
87            id: None,
88            echo: false,
89            format: false,
90            print_comments: false,
91        }
92    }
93}
94
95impl InterpreterOptions {
96    /// Create interpreter options.
97    pub fn new(
98        timeout: u64,
99        echo: bool,
100        format: bool,
101        print_comments: bool,
102    ) -> Self {
103        Self {
104            command: "sh -noprofile -norc".to_owned(),
105            prompt: None,
106            timeout: Some(timeout),
107            cinema: None,
108            id: None,
109            echo,
110            format,
111            print_comments,
112        }
113    }
114
115    /// Create interpreter options for asciinema recording.
116    pub fn new_recording(
117        output: impl AsRef<Path>,
118        overwrite: bool,
119        options: CinemaOptions,
120        timeout: u64,
121        echo: bool,
122        format: bool,
123        print_comments: bool,
124    ) -> Self {
125        let mut command = format!(
126            "asciinema rec {:#?}",
127            output.as_ref().to_string_lossy(),
128        );
129        if overwrite {
130            command.push_str(" --overwrite");
131        }
132        command.push_str(&format!(" --rows={}", options.rows));
133        command.push_str(&format!(" --cols={}", options.cols));
134        Self {
135            command,
136            prompt: None,
137            timeout: Some(timeout),
138            cinema: Some(options),
139            id: None,
140            echo,
141            format,
142            print_comments,
143        }
144    }
145}
146
147/// Script file.
148#[derive(Debug)]
149pub struct ScriptFile {
150    path: PathBuf,
151    source: ScriptSource,
152}
153
154impl ScriptFile {
155    /// Path to the source file.
156    pub fn path(&self) -> &PathBuf {
157        &self.path
158    }
159
160    /// Source contents of the file.
161    pub fn source(&self) -> &str {
162        self.source.borrow_source()
163    }
164
165    /// Script instructions.
166    pub fn instructions(&self) -> &Instructions<'_> {
167        self.source.borrow_instructions()
168    }
169}
170
171#[self_referencing]
172#[derive(Debug)]
173/// Script file.
174pub struct ScriptSource {
175    /// Script source.
176    pub source: String,
177    /// Parsed instructions.
178    #[borrows(source)]
179    #[covariant]
180    pub instructions: Instructions<'this>,
181}
182
183impl ScriptFile {
184    /// Parse a collection of files.
185    pub fn parse_files(paths: Vec<PathBuf>) -> Result<Vec<ScriptFile>> {
186        let mut results = Vec::new();
187        for path in paths {
188            let script = Self::parse(path)?;
189            results.push(script);
190        }
191        Ok(results)
192    }
193
194    /// Parse a single file.
195    pub fn parse(path: impl AsRef<Path>) -> Result<ScriptFile> {
196        let source = Self::parse_source(path.as_ref())?;
197        Ok(ScriptFile {
198            path: path.as_ref().to_owned(),
199            source,
200        })
201    }
202
203    fn parse_source(path: impl AsRef<Path>) -> Result<ScriptSource> {
204        let mut includes = Vec::new();
205        let source = std::fs::read_to_string(path.as_ref())?;
206        let mut source = ScriptSourceTryBuilder {
207            source,
208            instructions_builder: |source| {
209                let (instructions, mut file_includes) =
210                    ScriptParser::parse_file(source, path.as_ref())?;
211                includes.append(&mut file_includes);
212                Ok::<_, Error>(instructions)
213            },
214        }
215        .try_build()?;
216
217        let mut num_inserts = 0;
218        for raw in includes {
219            let src = Self::parse_source(&raw.path)?;
220            let instruction = Instruction::Include(src);
221            source.with_instructions_mut(|i| {
222                let index = raw.index + num_inserts;
223                if index < i.len() {
224                    i.insert(index, instruction);
225                } else {
226                    i.push(instruction);
227                }
228                num_inserts += 1;
229            });
230        }
231
232        Ok(source)
233    }
234
235    /// Execute the command and instructions in a pseudo-terminal.
236    pub fn run(&self, options: InterpreterOptions) -> Result<()> {
237        let cmd = options.command.clone();
238
239        let span = if let Some(id) = &options.id {
240            span!(Level::DEBUG, "run", id = id)
241        } else {
242            span!(Level::DEBUG, "run")
243        };
244
245        let _enter = span.enter();
246
247        let instructions = self.source.borrow_instructions();
248        let is_cinema = options.cinema.is_some();
249
250        let prompt =
251            options.prompt.clone().unwrap_or_else(|| PROMPT.to_owned());
252        std::env::set_var("PS1", &prompt);
253
254        if let Some(cinema) = &options.cinema {
255            // Export a vanilla shell for asciinema
256            let shell = format!("PS1='{}' {}", &prompt, cinema.shell);
257            std::env::set_var("SHELL", shell);
258        }
259
260        let pragma =
261            if let Some(Instruction::Pragma(cmd)) = instructions.first() {
262                Some(resolve_path(&self.path, cmd)?)
263            } else {
264                None
265            };
266
267        let exec_cmd = if let (false, Some(pragma)) = (is_cinema, &pragma) {
268            pragma.as_ref().to_owned()
269        } else {
270            cmd.to_owned()
271        };
272
273        tracing::info!(exec = %exec_cmd, "run");
274        let mut p = session(
275            &exec_cmd,
276            options.timeout,
277            prompt,
278            options.echo,
279            options.format,
280        )?;
281
282        if options.cinema.is_some() {
283            p.expect_prompt()?;
284            // Wait for the initial shell prompt to flush
285            sleep(Duration::from_millis(50));
286            tracing::debug!("ready");
287        }
288
289        fn type_text(
290            pty: &mut ReplSession,
291            text: &str,
292            cinema: &CinemaOptions,
293        ) -> Result<()> {
294            for c in UnicodeSegmentation::graphemes(text, true) {
295                pty.send(c)?;
296                pty.flush()?;
297
298                let mut source = Source(rand::rngs::OsRng);
299                let gaussian = Gaussian::new(0.0, cinema.deviation);
300                let drift = gaussian.sample(&mut source);
301
302                let delay = if (drift as u64) < cinema.delay {
303                    let drift = drift as i64;
304                    if drift < 0 {
305                        cinema.delay - drift.unsigned_abs()
306                    } else {
307                        cinema.delay + drift as u64
308                    }
309                } else {
310                    cinema.delay + drift.abs() as u64
311                };
312
313                sleep(Duration::from_millis(delay));
314            }
315
316            pty.send("\n")?;
317            pty.flush()?;
318
319            Ok(())
320        }
321
322        fn exec(
323            p: &mut ReplSession,
324            instructions: &[Instruction<'_>],
325            options: &InterpreterOptions,
326            pragma: Option<&str>,
327        ) -> Result<()> {
328            for cmd in instructions.iter() {
329                tracing::debug!(instruction = ?cmd);
330                match cmd {
331                    Instruction::Pragma(_) => {
332                        if let (Some(cinema), Some(cmd)) =
333                            (&options.cinema, &pragma)
334                        {
335                            if cinema.type_pragma {
336                                type_text(p, cmd, cinema)?;
337                            } else {
338                                p.send_line(cmd)?;
339                            }
340                        }
341                    }
342                    Instruction::Sleep(delay) => {
343                        sleep(Duration::from_millis(*delay));
344                    }
345                    Instruction::Send(line) => {
346                        p.send(line)?;
347                    }
348                    Instruction::Comment(line)
349                    | Instruction::SendLine(line) => {
350                        if let (false, Instruction::Comment(_)) =
351                            (options.print_comments, cmd)
352                        {
353                            continue;
354                        }
355
356                        let line = ScriptParser::interpolate(line)?;
357                        if let Some(cinema) = &options.cinema {
358                            type_text(p, line.as_ref(), cinema)?;
359                        } else {
360                            p.send_line(line.as_ref())?;
361                        }
362                    }
363                    Instruction::SendControl(ctrl) => {
364                        let ctrl =
365                            ControlCode::try_from(*ctrl).map_err(|_| {
366                                Error::InvalidControlCode(ctrl.to_string())
367                            })?;
368                        p.send(ctrl)?;
369                    }
370                    Instruction::Expect(line) => {
371                        p.expect(line)?;
372                    }
373                    Instruction::Regex(line) => {
374                        p.expect(Regex(line))?;
375                    }
376                    Instruction::ReadLine => {
377                        let mut line = String::new();
378                        p.read_line(&mut line)?;
379                    }
380                    Instruction::Wait => {
381                        p.expect_prompt()?;
382                    }
383                    Instruction::Clear => {
384                        p.send_line("clear")?;
385                    }
386                    Instruction::Flush => {
387                        p.flush()?;
388                    }
389                    Instruction::Include(source) => {
390                        exec(
391                            p,
392                            source.borrow_instructions(),
393                            options,
394                            pragma,
395                        )?;
396                    }
397                }
398
399                sleep(Duration::from_millis(15));
400            }
401            Ok(())
402        }
403
404        exec(
405            &mut p,
406            instructions,
407            &options,
408            pragma.as_ref().map(|i| i.as_ref()),
409        )?;
410
411        if options.cinema.is_some() {
412            tracing::debug!("exit");
413            p.send(ControlCode::EndOfTransmission)?;
414        } else {
415            tracing::debug!("eof");
416            // If it's not a shell, ie: has a pragma command
417            // which is a script this will fail with I/O error
418            // but we can safely ignore it
419            let _ = p.send(ControlCode::EndOfTransmission);
420        }
421
422        Ok(())
423    }
424}
425
426fn session(
427    cmd: &str,
428    _timeout: Option<u64>,
429    prompt: String,
430    echo: bool,
431    format: bool,
432) -> Result<ReplSession> {
433    use std::process::Command;
434    let mut parts = comma::parse_command(cmd)
435        .ok_or(Error::BadArguments(cmd.to_owned()))?;
436    let prog = parts.remove(0);
437    let mut command = Command::new(prog);
438    command.args(parts);
439
440    let pty = Session::spawn(command)?;
441    if echo && format {
442        Ok(ReplSession::new_log(
443            log(pty, std::io::stdout())?,
444            prompt,
445            None,
446            false,
447        ))
448    } else if echo && !format {
449        Ok(ReplSession::new_tee(
450            tee(pty, std::io::stdout())?,
451            prompt,
452            None,
453            false,
454        ))
455    } else {
456        Ok(ReplSession::new(pty, prompt, None, false))
457    }
458}