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
29struct 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#[derive(Debug, Clone)]
40pub struct CinemaOptions {
41 pub delay: u64,
43 pub type_pragma: bool,
45 pub deviation: f64,
47 pub shell: String,
49 pub cols: u64,
51 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
68pub struct InterpreterOptions {
70 pub command: String,
72 pub timeout: Option<u64>,
74 pub cinema: Option<CinemaOptions>,
76 pub id: Option<String>,
78 pub prompt: Option<String>,
80 pub echo: bool,
82 pub format: bool,
84 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 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 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#[derive(Debug)]
157pub struct ScriptFile {
158 path: PathBuf,
159 source: ScriptSource,
160}
161
162impl ScriptFile {
163 pub fn path(&self) -> &PathBuf {
165 &self.path
166 }
167
168 pub fn source(&self) -> &str {
170 self.source.borrow_source()
171 }
172
173 pub fn instructions(&self) -> &Instructions<'_> {
175 self.source.borrow_instructions()
176 }
177}
178
179#[self_referencing]
180#[derive(Debug)]
181pub struct ScriptSource {
183 pub source: String,
185 #[borrows(source)]
187 #[covariant]
188 pub instructions: Instructions<'this>,
189}
190
191impl ScriptFile {
192 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 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 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 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 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 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}