anticipate_runner/
interpreter.rs

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