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#[derive(Debug, Clone)]
32pub struct CinemaOptions {
33 pub delay: u64,
35 pub type_pragma: bool,
37 pub deviation: f64,
39 pub shell: String,
41 pub cols: u64,
43 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
60pub struct InterpreterOptions {
62 pub command: String,
64 pub timeout: Option<u64>,
66 pub cinema: Option<CinemaOptions>,
68 pub id: Option<String>,
70 pub prompt: Option<String>,
72 pub echo: bool,
74 pub format: bool,
76 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 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 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#[derive(Debug)]
149pub struct ScriptFile {
150 path: PathBuf,
151 source: ScriptSource,
152}
153
154impl ScriptFile {
155 pub fn path(&self) -> &PathBuf {
157 &self.path
158 }
159
160 pub fn source(&self) -> &str {
162 self.source.borrow_source()
163 }
164
165 pub fn instructions(&self) -> &Instructions<'_> {
167 self.source.borrow_instructions()
168 }
169}
170
171#[self_referencing]
172#[derive(Debug)]
173pub struct ScriptSource {
175 pub source: String,
177 #[borrows(source)]
179 #[covariant]
180 pub instructions: Instructions<'this>,
181}
182
183impl ScriptFile {
184 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 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 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 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 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 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}