Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow code snippet execution in any language #253

Merged
merged 6 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ use std::{

// Take all files under `themes` and turn them into a file that contains a hashmap with their
// contents by name. This is pulled in theme.rs to construct themes.
fn main() -> io::Result<()> {
let out_dir = env::var("OUT_DIR").unwrap();
fn build_themes(out_dir: &str) -> io::Result<()> {
let output_path = format!("{out_dir}/themes.rs");
let mut output_file = BufWriter::new(File::create(output_path)?);
output_file.write_all(b"use std::collections::BTreeMap as Map;\n")?;
output_file.write_all(b"use once_cell::sync::Lazy;\n")?;
output_file.write_all(b"static THEMES: Lazy<Map<&'static str, &'static [u8]>> = Lazy::new(|| Map::from([\n")?;

let mut paths = fs::read_dir("themes")?.collect::<io::Result<Vec<_>>>()?;
paths.sort_by_key(|e| e.path());
for theme_file in paths {
Expand All @@ -33,3 +33,43 @@ fn main() -> io::Result<()> {
println!("cargo:rerun-if-changed=themes");
Ok(())
}

fn build_executors(out_dir: &str) -> io::Result<()> {
let output_path = format!("{out_dir}/executors.rs");
let mut output_file = BufWriter::new(File::create(output_path)?);
output_file.write_all(b"use std::collections::BTreeMap as Map;\n")?;
output_file.write_all(b"use once_cell::sync::Lazy;\n")?;
output_file.write_all(b"static EXECUTORS: Lazy<Map<crate::markdown::elements::CodeLanguage, &'static [u8]>> = Lazy::new(|| Map::from([\n")?;

let mut paths = fs::read_dir("executors")?.collect::<io::Result<Vec<_>>>()?;
paths.sort_by_key(|e| e.path());
for file in paths {
let metadata = file.metadata()?;
if !metadata.is_file() {
panic!("found non file in executors directory");
}
let path = file.path();
let contents = fs::read(&path)?;
let file_name = path.file_name().unwrap().to_string_lossy();
let (executor_name, extension) = file_name.split_once('.').unwrap();
if extension != "sh" {
panic!("extension must be 'sh'");
}
output_file.write_all(
format!("(crate::markdown::elements::CodeLanguage::{executor_name}, {contents:?}.as_slice()),\n")
.as_bytes(),
)?;
}
output_file.write_all(b"]));\n")?;

// Rebuild if anything changes.
println!("cargo:rerun-if-changed=executors");
Ok(())
}

fn main() -> io::Result<()> {
let out_dir = env::var("OUT_DIR").unwrap();
build_themes(&out_dir)?;
build_executors(&out_dir)?;
Ok(())
}
3 changes: 3 additions & 0 deletions executors/Python.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash

exec python -u "$1"
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"src"
"themes"
"bat"
"executors"
];

buildSrc = flakeboxLib.filterSubPaths {
Expand Down
5 changes: 4 additions & 1 deletion src/demo.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{
execute::CodeExecutor,
input::{
source::Command,
user::{CommandKeyBindings, UserInput},
Expand All @@ -9,7 +10,7 @@ use crate::{
render::{draw::TerminalDrawer, terminal::TerminalWrite},
ImageRegistry, MarkdownParser, PresentationBuilderOptions, PresentationTheme, Resources, Themes, TypstRender,
};
use std::io;
use std::{io, rc::Rc};

const PRESENTATION: &str = r#"
# Header 1
Expand Down Expand Up @@ -101,11 +102,13 @@ impl<W: TerminalWrite> ThemesDemo<W> {
let mut resources = Resources::new("non_existent", image_registry.clone());
let mut typst = TypstRender::default();
let options = PresentationBuilderOptions::default();
let executer = Rc::new(CodeExecutor::default());
let bindings_config = Default::default();
let builder = PresentationBuilder::new(
theme,
&mut resources,
&mut typst,
executer,
&self.themes,
image_registry,
bindings_config,
Expand Down
104 changes: 90 additions & 14 deletions src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,95 @@

use crate::markdown::elements::{Code, CodeLanguage};
use std::{
collections::BTreeMap,
ffi::OsStr,
fs,
io::{self, BufRead, BufReader, Write},
path::{Path, PathBuf},
process::{self, Stdio},
sync::{Arc, Mutex},
thread,
};
use tempfile::NamedTempFile;

include!(concat!(env!("OUT_DIR"), "/executors.rs"));

/// Allows executing code.
pub(crate) struct CodeExecuter;
#[derive(Default, Debug)]
pub struct CodeExecutor {
custom_executors: BTreeMap<CodeLanguage, Vec<u8>>,
}

impl CodeExecuter {
/// Execute a piece of code.
pub(crate) fn execute(code: &Code) -> Result<ExecutionHandle, CodeExecuteError> {
if !code.language.supports_execution() {
return Err(CodeExecuteError::UnsupportedExecution);
impl CodeExecutor {
pub fn load(executors_path: &Path) -> Result<Self, LoadExecutorsError> {
let mut custom_executors = BTreeMap::new();
if let Ok(paths) = fs::read_dir(executors_path) {
for executor in paths {
let executor = executor?;
let path = executor.path();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
let Some((name, extension)) = filename.split_once('.') else {
return Err(LoadExecutorsError::InvalidExecutor(path, "no extension"));
};
if extension != "sh" {
return Err(LoadExecutorsError::InvalidExecutor(path, "non .sh extension"));
}
let language: CodeLanguage = match name.parse() {
Ok(language) => language,
Err(_) => return Err(LoadExecutorsError::InvalidExecutor(path, "invalid code language")),
};
let file_contents = fs::read(path)?;
custom_executors.insert(language, file_contents);
}
}
Ok(Self { custom_executors })
}

pub(crate) fn is_execution_supported(&self, language: &CodeLanguage) -> bool {
if matches!(language, CodeLanguage::Shell(_)) {
true
} else {
EXECUTORS.contains_key(language) || self.custom_executors.contains_key(language)
}
}

/// Execute a piece of code.
pub(crate) fn execute(&self, code: &Code) -> Result<ExecutionHandle, CodeExecuteError> {
if !code.attributes.execute {
return Err(CodeExecuteError::NotExecutableCode);
}
match &code.language {
CodeLanguage::Shell(interpreter) => Self::execute_shell(interpreter, &code.contents),
_ => Err(CodeExecuteError::UnsupportedExecution),
CodeLanguage::Shell(interpreter) => {
let args: &[&str] = &[];
Self::execute_shell(interpreter, code.contents.as_bytes(), args)
}
lang => {
let executor = self.executor(lang).ok_or(CodeExecuteError::UnsupportedExecution)?;
Self::execute_lang(executor, code.contents.as_bytes())
}
}
}

fn execute_shell(interpreter: &str, code: &str) -> Result<ExecutionHandle, CodeExecuteError> {
fn executor(&self, language: &CodeLanguage) -> Option<&[u8]> {
if let Some(executor) = self.custom_executors.get(language) {
return Some(executor);
}
EXECUTORS.get(language).copied()
}

fn execute_shell<S>(interpreter: &str, code: &[u8], args: &[S]) -> Result<ExecutionHandle, CodeExecuteError>
where
S: AsRef<OsStr>,
{
let mut output_file = NamedTempFile::new().map_err(CodeExecuteError::TempFile)?;
output_file.write_all(code.as_bytes()).map_err(CodeExecuteError::TempFile)?;
output_file.write_all(code).map_err(CodeExecuteError::TempFile)?;
output_file.flush().map_err(CodeExecuteError::TempFile)?;
let (reader, writer) = os_pipe::pipe().map_err(CodeExecuteError::Pipe)?;
let writer_clone = writer.try_clone().map_err(CodeExecuteError::Pipe)?;
let process_handle = process::Command::new("/usr/bin/env")
.arg(interpreter)
.arg(output_file.path())
.args(args)
.stdin(Stdio::null())
.stdout(writer)
.stderr(writer_clone)
Expand All @@ -44,9 +99,29 @@ impl CodeExecuter {

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

fn execute_lang(executor: &[u8], code: &[u8]) -> Result<ExecutionHandle, CodeExecuteError> {
let mut code_file = NamedTempFile::new().map_err(CodeExecuteError::TempFile)?;
code_file.write_all(code).map_err(CodeExecuteError::TempFile)?;

let path = code_file.path();
let mut handle = Self::execute_shell("bash", executor, &[path])?;
handle.program_path = Some(code_file);
Ok(handle)
}
}

/// An error during the load of custom executors.
#[derive(thiserror::Error, Debug)]
pub enum LoadExecutorsError {
#[error("io: {0}")]
Io(#[from] io::Error),

#[error("invalid executor '{0}': {1}")]
InvalidExecutor(PathBuf, &'static str),
}

/// An error during the execution of some code.
Expand Down Expand Up @@ -74,6 +149,7 @@ pub(crate) struct ExecutionHandle {
state: Arc<Mutex<ExecutionState>>,
#[allow(dead_code)]
reader_handle: thread::JoinHandle<()>,
program_path: Option<NamedTempFile>,
}

impl ExecutionHandle {
Expand Down Expand Up @@ -166,7 +242,7 @@ echo 'bye'"
language: CodeLanguage::Shell("sh".into()),
attributes: CodeAttributes { execute: true, ..Default::default() },
};
let handle = CodeExecuter::execute(&code).expect("execution failed");
let handle = CodeExecutor::default().execute(&code).expect("execution failed");
let state = loop {
let state = handle.state();
if state.status.is_finished() {
Expand All @@ -186,7 +262,7 @@ echo 'bye'"
language: CodeLanguage::Shell("sh".into()),
attributes: CodeAttributes { execute: false, ..Default::default() },
};
let result = CodeExecuter::execute(&code);
let result = CodeExecutor::default().execute(&code);
assert!(result.is_err());
}

Expand All @@ -202,7 +278,7 @@ echo 'hello world'
language: CodeLanguage::Shell("sh".into()),
attributes: CodeAttributes { execute: true, ..Default::default() },
};
let handle = CodeExecuter::execute(&code).expect("execution failed");
let handle = CodeExecutor::default().execute(&code).expect("execution failed");
let state = loop {
let state = handle.state();
if state.status.is_finished() {
Expand Down
13 changes: 9 additions & 4 deletions src/export.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::{
custom::KeyBindingsConfig,
execute::CodeExecutor,
markdown::parse::ParseError,
media::{
image::{Image, ImageSource},
Expand All @@ -16,9 +17,9 @@ use image::{codecs::png::PngEncoder, DynamicImage, ImageEncoder, ImageError};
use semver::Version;
use serde::Serialize;
use std::{
env, fs,
io::{self},
env, fs, io,
path::{Path, PathBuf},
rc::Rc,
};

const MINIMUM_EXPORTER_VERSION: Version = Version::new(0, 2, 0);
Expand All @@ -29,6 +30,7 @@ pub struct Exporter<'a> {
default_theme: &'a PresentationTheme,
resources: Resources,
typst: TypstRender,
code_executor: Rc<CodeExecutor>,
themes: Themes,
options: PresentationBuilderOptions,
}
Expand All @@ -40,10 +42,11 @@ impl<'a> Exporter<'a> {
default_theme: &'a PresentationTheme,
resources: Resources,
typst: TypstRender,
code_executor: Rc<CodeExecutor>,
themes: Themes,
options: PresentationBuilderOptions,
) -> Self {
Self { parser, default_theme, resources, typst, themes, options }
Self { parser, default_theme, resources, typst, code_executor, themes, options }
}

/// Export the given presentation into PDF.
Expand Down Expand Up @@ -83,6 +86,7 @@ impl<'a> Exporter<'a> {
self.default_theme,
&mut self.resources,
&mut self.typst,
self.code_executor.clone(),
&self.themes,
Default::default(),
KeyBindingsConfig::default(),
Expand Down Expand Up @@ -299,9 +303,10 @@ mod test {
let theme = PresentationThemeSet::default().load_by_name("dark").unwrap();
let resources = Resources::new("examples", Default::default());
let typst = TypstRender::default();
let code_executor = Default::default();
let themes = Themes::default();
let options = PresentationBuilderOptions { allow_mutations: false, ..Default::default() };
let mut exporter = Exporter::new(parser, &theme, resources, typst, themes, options);
let mut exporter = Exporter::new(parser, &theme, resources, typst, code_executor, themes, options);
exporter.extract_metadata(content, Path::new(path)).expect("metadata extraction failed")
}

Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub(crate) mod typst;
pub use crate::{
custom::{Config, ImageProtocol, ValidateOverflows},
demo::ThemesDemo,
execute::CodeExecutor,
export::{ExportError, Exporter},
input::source::CommandSource,
markdown::parse::MarkdownParser,
Expand Down
Loading
Loading