Skip to content

Commit

Permalink
Add code executer
Browse files Browse the repository at this point in the history
  • Loading branch information
mfontanini committed Oct 21, 2023
1 parent f4fc5d0 commit 973e988
Show file tree
Hide file tree
Showing 8 changed files with 204 additions and 16 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ serde_yaml = "0.9"
serde_with = "3.3"
syntect = "5.1"
strum = { version = "0.25", features = ["derive"] }
tempfile = "3.8"
thiserror = "1"
unicode-width = "0.1"
viuer = "0.7.1"
Expand Down
4 changes: 2 additions & 2 deletions src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ mod test {
use rstest::rstest;

use super::*;
use crate::{markdown::elements::ProgrammingLanguage, presentation::PreformattedLine};
use crate::{markdown::elements::CodeLanguage, presentation::PreformattedLine};

fn build_presentation(elements: Vec<MarkdownElement>) -> Presentation {
try_build_presentation(elements).expect("build failed")
Expand Down Expand Up @@ -819,7 +819,7 @@ mod test {
MarkdownElement::BlockQuote(vec![text.clone()]),
MarkdownElement::Code(Code {
contents: text.clone(),
language: ProgrammingLanguage::Unknown,
language: CodeLanguage::Unknown,
flags: Default::default(),
}),
];
Expand Down
185 changes: 185 additions & 0 deletions src/execute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//! Code execution.
use crate::markdown::elements::{Code, CodeLanguage};
use std::{
io::{self, BufRead, BufReader, Write},
process::{self, ChildStdout, Stdio},
sync::{Arc, Mutex},
thread::{self},
};
use tempfile::NamedTempFile;

/// Allows executing code.
pub struct CodeExecuter;

impl CodeExecuter {
/// Execute a piece of code.
pub fn execute(code: &Code) -> Result<ExecutionHandle, CodeExecuteError> {
if !code.language.supports_execution() {
return Err(CodeExecuteError::UnsupportedExecution);
}
if !code.flags.execute {
return Err(CodeExecuteError::NotExecutableCode);
}
match &code.language {
CodeLanguage::Shell(interpreter) => Self::execute_shell(interpreter, &code.contents),
_ => Err(CodeExecuteError::UnsupportedExecution),
}
}

fn execute_shell(interpreter: &str, code: &str) -> Result<ExecutionHandle, CodeExecuteError> {
let mut output_file = NamedTempFile::new().map_err(CodeExecuteError::TempFile)?;
output_file.write_all(code.as_bytes()).map_err(CodeExecuteError::TempFile)?;
output_file.flush().map_err(CodeExecuteError::TempFile)?;
let process_handle = process::Command::new("/usr/bin/env")
.arg(interpreter)
.arg(output_file.path())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(CodeExecuteError::SpawnProcess)?;

let state: Arc<Mutex<ExecutionState>> = Default::default();
let reader_handle = ProcessReader::spawn(process_handle, state.clone(), output_file);
let handle = ExecutionHandle { state, reader_handle };
Ok(handle)
}
}

/// An error during the execution of some code.
#[derive(thiserror::Error, Debug)]
pub enum CodeExecuteError {
#[error("code language doesn't support execution")]
UnsupportedExecution,

#[error("code is not marked for execution")]
NotExecutableCode,

#[error("error creating temporary file: {0}")]
TempFile(io::Error),

#[error("error spawning process: {0}")]
SpawnProcess(io::Error),
}

/// A handle for the execution of a piece of code.
pub struct ExecutionHandle {
state: Arc<Mutex<ExecutionState>>,
#[allow(dead_code)]
reader_handle: thread::JoinHandle<()>,
}

impl ExecutionHandle {
/// Get the current state of the process.
pub fn state(&self) -> ExecutionState {
self.state.lock().unwrap().clone()
}
}

/// Consumes the output of a process and stores it in a shared state.
struct ProcessReader {
handle: process::Child,
state: Arc<Mutex<ExecutionState>>,
#[allow(dead_code)]
file_handle: NamedTempFile,
}

impl ProcessReader {
fn spawn(
handle: process::Child,
state: Arc<Mutex<ExecutionState>>,
file_handle: NamedTempFile,
) -> thread::JoinHandle<()> {
let reader = Self { handle, state, file_handle };
thread::spawn(|| reader.run())
}

fn run(mut self) {
let stdout = self.handle.stdout.take().expect("no stdout");
let stdout = BufReader::new(stdout);
let _ = Self::process_output(self.state.clone(), stdout);
let success = match self.handle.try_wait() {
Ok(Some(code)) => {
println!("Exit code {code:?}");
code.success()
}
_ => false,
};
let status = match success {
true => ProcessStatus::Success,
false => ProcessStatus::Failure,
};
self.state.lock().unwrap().status = status;
}

fn process_output(state: Arc<Mutex<ExecutionState>>, stdout: BufReader<ChildStdout>) -> io::Result<()> {
for line in stdout.lines() {
let line = line?;
// TODO: consider not locking per line...
state.lock().unwrap().output.push(line);
}
Ok(())
}
}

/// The state of the execution of a process.
#[derive(Clone, Default)]
pub struct ExecutionState {
output: Vec<String>,
status: ProcessStatus,
}

impl ExecutionState {
/// Check whether the underlying process is finished.
pub fn is_finished(&self) -> bool {
matches!(self.status, ProcessStatus::Success | ProcessStatus::Failure)
}

/// Extract the lines printed so far.
pub fn into_lines(self) -> Vec<String> {
self.output
}
}

/// The status of a process.
#[derive(Clone, Debug, Default)]
pub enum ProcessStatus {
#[default]
Running,
Success,
Failure,
}

#[cfg(test)]
mod test {
use super::*;
use crate::markdown::elements::CodeFlags;

#[test]
fn shell_code_execution() {
let contents = r"
echo 'hello world'
echo 'bye'"
.into();
let code = Code { contents, language: CodeLanguage::Shell("sh".into()), flags: CodeFlags { execute: true } };
let handle = CodeExecuter::execute(&code).expect("execution failed");
let state = loop {
let state = handle.state();
if state.is_finished() {
break state;
}
};

let expected_lines = vec!["hello world", "bye"];
assert_eq!(state.into_lines(), expected_lines);
}

#[test]
fn non_executable_code_cant_be_executed() {
let contents = String::new();
let code = Code { contents, language: CodeLanguage::Shell("sh".into()), flags: CodeFlags { execute: false } };
let result = CodeExecuter::execute(&code);
assert!(result.is_err());
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
pub mod builder;
pub mod diff;
pub mod execute;
pub mod input;
pub mod markdown;
pub mod presentation;
Expand Down
8 changes: 4 additions & 4 deletions src/markdown/elements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,15 @@ pub struct Code {
pub contents: String,

/// The programming language this code is written in.
pub language: ProgrammingLanguage,
pub language: CodeLanguage,

/// The flags used for this code.
pub flags: CodeFlags,
}

/// A programming language.
/// The language of a piece of code.
#[derive(Clone, Debug, PartialEq, Eq, EnumIter)]
pub enum ProgrammingLanguage {
pub enum CodeLanguage {
Asp,
Bash,
BatchFile,
Expand Down Expand Up @@ -195,7 +195,7 @@ pub enum ProgrammingLanguage {
Yaml,
}

impl ProgrammingLanguage {
impl CodeLanguage {
pub fn supports_execution(&self) -> bool {
matches!(self, Self::Shell(_))
}
Expand Down
10 changes: 5 additions & 5 deletions src/markdown/parse.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::{
markdown::elements::{
Code, CodeFlags, ListItem, ListItemType, MarkdownElement, ParagraphElement, ProgrammingLanguage, StyledText,
Table, TableRow, Text,
Code, CodeFlags, CodeLanguage, ListItem, ListItemType, MarkdownElement, ParagraphElement, StyledText, Table,
TableRow, Text,
},
style::TextStyle,
};
Expand Down Expand Up @@ -123,7 +123,7 @@ impl<'a> MarkdownParser<'a> {
if !block.fenced {
return Err(ParseErrorKind::UnfencedCodeBlock.with_sourcepos(sourcepos));
}
use ProgrammingLanguage::*;
use CodeLanguage::*;
let info = block.info.as_str();
let mut tokens = info.split(' ');
let language = match tokens.next().unwrap_or("") {
Expand Down Expand Up @@ -636,7 +636,7 @@ let q = 42;
",
);
let MarkdownElement::Code(code) = parsed else { panic!("not a code block: {parsed:?}") };
assert_eq!(code.language, ProgrammingLanguage::Rust);
assert_eq!(code.language, CodeLanguage::Rust);
assert_eq!(code.contents, "let q = 42;\n");
assert!(!code.flags.execute);
}
Expand All @@ -651,7 +651,7 @@ echo hi mom
",
);
let MarkdownElement::Code(code) = parsed else { panic!("not a code block: {parsed:?}") };
assert_eq!(code.language, ProgrammingLanguage::Shell("bash".into()));
assert_eq!(code.language, CodeLanguage::Shell("bash".into()));
assert!(code.flags.execute);
}

Expand Down
10 changes: 5 additions & 5 deletions src/render/highlighting.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::markdown::elements::ProgrammingLanguage;
use crate::markdown::elements::CodeLanguage;
use once_cell::sync::Lazy;
use syntect::{
easy::HighlightLines,
Expand Down Expand Up @@ -26,7 +26,7 @@ impl CodeHighlighter {
/// Highlight a piece of code.
///
/// This splits the given piece of code into lines, highlights them individually, and returns them.
pub fn highlight<'a>(&self, code: &'a str, language: &ProgrammingLanguage) -> Vec<CodeLine<'a>> {
pub fn highlight<'a>(&self, code: &'a str, language: &CodeLanguage) -> Vec<CodeLine<'a>> {
let extension = Self::language_extension(language);
let syntax = SYNTAX_SET.find_syntax_by_extension(extension).unwrap();
let mut highlight_lines = HighlightLines::new(syntax, self.theme);
Expand All @@ -40,8 +40,8 @@ impl CodeHighlighter {
lines
}

fn language_extension(language: &ProgrammingLanguage) -> &'static str {
use ProgrammingLanguage::*;
fn language_extension(language: &CodeLanguage) -> &'static str {
use CodeLanguage::*;
match language {
Asp => "asa",
Bash => "bash",
Expand Down Expand Up @@ -104,7 +104,7 @@ mod test {

#[test]
fn language_extensions_exist() {
for language in ProgrammingLanguage::iter() {
for language in CodeLanguage::iter() {
let extension = CodeHighlighter::language_extension(&language);
let syntax = SYNTAX_SET.find_syntax_by_extension(extension);
assert!(syntax.is_some(), "extension {extension} for {language:?} not found");
Expand Down

0 comments on commit 973e988

Please sign in to comment.