From 894ce397da5cccea0d6dd274c828caae9b52bf95 Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Sat, 25 Apr 2026 15:51:28 -0700 Subject: [PATCH] feat: allow passing in env file to snippet --- src/code/execute.rs | 24 +++++++++--- src/code/snippet.rs | 6 +++ src/presentation/builder/error.rs | 3 ++ src/presentation/builder/snippet.rs | 60 +++++++++++++++++++++++++++-- src/ui/execution/output.rs | 4 +- src/ui/execution/validator.rs | 8 +++- 6 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/code/execute.rs b/src/code/execute.rs index 246fc36..61bb272 100644 --- a/src/code/execute.rs +++ b/src/code/execute.rs @@ -66,6 +66,7 @@ impl SnippetExecutor { &self, language: &SnippetLanguage, spec: &SnippetExecutorSpec, + custom_env: HashMap, ) -> Result { let language_config = self .executors @@ -79,10 +80,14 @@ impl SnippetExecutor { })? } }; + + let mut env = config.environment.clone(); + env.extend(custom_env); Ok(LanguageSnippetExecutor { hidden_line_prefix: language_config.hidden_line_prefix.clone(), config, cwd: self.cwd.clone(), + env, }) } @@ -126,6 +131,7 @@ pub(crate) struct LanguageSnippetExecutor { hidden_line_prefix: Option, config: SnippetExecutorConfig, cwd: PathBuf, + env: HashMap, } impl LanguageSnippetExecutor { @@ -141,7 +147,7 @@ impl LanguageSnippetExecutor { state.clone(), script_dir, self.config.commands.clone(), - self.config.environment.clone(), + self.env.clone(), self.cwd.clone(), output_type, ); @@ -177,7 +183,7 @@ impl LanguageSnippetExecutor { let mut command = portable_pty::CommandBuilder::new(command); command.args(args); command.cwd(&self.cwd); - for (key, value) in &self.config.environment { + for (key, value) in &self.env { command.env(key, value); } Ok(PtySnippetContext { command, _temp: script_dir }) @@ -190,7 +196,7 @@ impl LanguageSnippetExecutor { let (command, args) = commands.split_first().expect("no commands"); let child = process::Command::new(command) .args(args) - .envs(&self.config.environment) + .envs(&self.env) .current_dir(&self.cwd) .stderr(Stdio::piped()) .spawn() @@ -419,7 +425,9 @@ echo 'bye'" ..Default::default() }, }; - let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); + let executor = SnippetExecutor::default() + .language_executor(&snippet.language, &Default::default(), Default::default()) + .unwrap(); let handle = executor.execute_async(&snippet).expect("execution failed"); let state = loop { let state = handle.state.lock().unwrap(); @@ -447,7 +455,9 @@ echo 'hello world' ..Default::default() }, }; - let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); + let executor = SnippetExecutor::default() + .language_executor(&snippet.language, &Default::default(), Default::default()) + .unwrap(); let handle = executor.execute_async(&snippet).expect("execution failed"); let state = loop { let state = handle.state.lock().unwrap(); @@ -476,7 +486,9 @@ echo 'hello world' ..Default::default() }, }; - let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); + let executor = SnippetExecutor::default() + .language_executor(&snippet.language, &Default::default(), Default::default()) + .unwrap(); let handle = executor.execute_async(&snippet).expect("execution failed"); let state = loop { let state = handle.state.lock().unwrap(); diff --git a/src/code/snippet.rs b/src/code/snippet.rs index 9c3c80a..cb136a0 100644 --- a/src/code/snippet.rs +++ b/src/code/snippet.rs @@ -289,6 +289,7 @@ impl SnippetParser { HighlightedLines(lines) => attributes.highlight_groups = lines, Width(width) => attributes.width = Some(width), ExpectedExecutionResult(result) => attributes.expected_execution_result = result, + Env(path) => attributes.env_file = Some(path), }; processed_attributes.push(discriminant); input = rest; @@ -352,6 +353,7 @@ impl SnippetParser { } }, "pty" => SnippetAttribute::ExecPty(SnippetExecutorSpec::default(), parameter.parse()?), + "env" => SnippetAttribute::Env(parameter.to_string().into()), _ => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())), } } @@ -471,6 +473,7 @@ enum SnippetAttribute { AcquireTerminal(SnippetExecutorSpec), ExpectedExecutionResult(ExpectedSnippetExecutionResult), Id(String), + Env(PathBuf), } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -717,6 +720,9 @@ pub(crate) struct SnippetAttributes { /// The identifier for a snippet. pub(crate) id: Option, + + /// The environment file to load. + pub(crate) env_file: Option, } #[derive(Clone, Debug, Default, PartialEq, Eq)] diff --git a/src/presentation/builder/error.rs b/src/presentation/builder/error.rs index ca86b8a..291b21c 100644 --- a/src/presentation/builder/error.rs +++ b/src/presentation/builder/error.rs @@ -122,6 +122,9 @@ pub(crate) enum InvalidPresentation { #[error("snippet id '{0}' already exists")] SnippetAlreadyExists(String), + + #[error("invalid env file format: expected =")] + InvalidEnvFile, } #[derive(Clone, Debug)] diff --git a/src/presentation/builder/snippet.rs b/src/presentation/builder/snippet.rs index 5caaa21..6b338a9 100644 --- a/src/presentation/builder/snippet.rs +++ b/src/presentation/builder/snippet.rs @@ -25,7 +25,7 @@ use crate::{ }, }; use itertools::Itertools; -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc}; impl PresentationBuilder<'_, '_> { pub(crate) fn push_code(&mut self, info: String, code: String, source_position: SourcePosition) -> BuildResult { @@ -61,7 +61,11 @@ impl PresentationBuilder<'_, '_> { (SnippetExecutorSpec::Default, SnippetExecution::Exec(args)) => &args.spec, _ => spec, }; - let executor = self.snippet_executor.language_executor(&snippet.language, spec)?; + let env = match &snippet.attributes.env_file { + Some(path) => self.load_env(path, source_position)?, + None => HashMap::new(), + }; + let executor = self.snippet_executor.language_executor(&snippet.language, spec, env)?; self.push_validator(&snippet, &executor); } @@ -85,7 +89,11 @@ impl PresentationBuilder<'_, '_> { self.push_execution_disabled_operation(exec_type); } SnippetExecution::Exec(args) => { - let executor = self.snippet_executor.language_executor(&snippet.language, &args.spec)?; + let env = match &snippet.attributes.env_file { + Some(path) => self.load_env(path, source_position)?, + None => HashMap::new(), + }; + let executor = self.snippet_executor.language_executor(&snippet.language, &args.spec, env)?; match args.repr { SnippetRepr::Image => { self.push_code_as_image(snippet, executor)?; @@ -404,6 +412,31 @@ impl PresentationBuilder<'_, '_> { let operation = ValidateSnippetOperation::new(snippet.clone(), executor.clone()); self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation))); } + + fn load_env(&self, path: &Path, source_position: SourcePosition) -> Result, BuildError> { + let base = self.resource_base_path(); + let contents = self.resources.external_text_file(path, &base).map_err(|e| { + self.invalid_presentation( + source_position, + InvalidPresentation::IncludeMarkdown { path: path.to_path_buf(), error: e }, + ) + })?; + let mut vars = HashMap::new(); + for line in contents.lines() { + if line.starts_with('#') { + continue; + } + let line = line.trim(); + if line.is_empty() { + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(self.invalid_presentation(source_position, InvalidPresentation::InvalidEnvFile)); + }; + vars.insert(key.to_string(), value.to_string()); + } + Ok(vars) + } } #[derive(Debug)] @@ -889,4 +922,25 @@ echo "{qr}" let expected: Vec<_> = [empty.as_str()].into_iter().chain(qr.lines()).chain([empty.as_str()]).collect(); assert_eq!(lines, expected); } + + #[test] + fn env_file() { + let temp = tempfile::NamedTempFile::new().expect("failed to create tempfile"); + let path = temp.path(); + fs::write(path, "FOO=42\nBAR=1337\n\nTAR=hi\n# a comment\n").unwrap(); + + let path = path.to_string_lossy(); + let input = format!( + " +```bash +exec_replace +env:{path} +echo \"$FOO\" +echo \"$BAR\" +echo \"$TAR\" +``` +" + ); + let lines = Test::new(input).render().rows(5).columns(5).into_lines(); + let expected = &[" ", "42 ", "1337 ", "hi ", " "]; + assert_eq!(lines, expected); + } } diff --git a/src/ui/execution/output.rs b/src/ui/execution/output.rs index f706cdb..4321581 100644 --- a/src/ui/execution/output.rs +++ b/src/ui/execution/output.rs @@ -346,7 +346,9 @@ mod tests { ..Default::default() }, }; - let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); + let executor = SnippetExecutor::default() + .language_executor(&snippet.language, &Default::default(), Default::default()) + .unwrap(); let policy = RenderAsyncStartPolicy::OnDemand; let handle = SnippetHandle::new(snippet, executor, policy); RunSnippetTrigger::new(handle) diff --git a/src/ui/execution/validator.rs b/src/ui/execution/validator.rs index fddf1b7..dec1ffe 100644 --- a/src/ui/execution/validator.rs +++ b/src/ui/execution/validator.rs @@ -127,7 +127,9 @@ mod tests { language: SnippetLanguage::Rust, attributes: SnippetAttributes { expected_execution_result, ..Default::default() }, }; - let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); + let executor = SnippetExecutor::default() + .language_executor(&snippet.language, &Default::default(), Default::default()) + .unwrap(); let state = Arc::new(Mutex::new(State::default())); let mut pollable = OperationPollable { snippet: snippet.clone(), executor: executor.clone(), state: state.clone() }; @@ -154,7 +156,9 @@ mod tests { language: SnippetLanguage::Rust, attributes: SnippetAttributes { expected_execution_result, ..Default::default() }, }; - let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); + let executor = SnippetExecutor::default() + .language_executor(&snippet.language, &Default::default(), Default::default()) + .unwrap(); let state = Arc::new(Mutex::new(State::default())); let mut pollable = OperationPollable { snippet: snippet.clone(), executor: executor.clone(), state: state.clone() };