use crate::config::{Runner, DELIMITER};
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use std::env;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
static UNIFIED_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?s)(?P<runner_def><!--\s*inscribe\s+(?P<runner_lang>\w+)[^>]*?-->)|(?P<inscribe_block><!--\s*inscribe\s*-->\s*```(?P<lang_fenced>\w+)\r?\n(?P<code_fenced>.*?)```(?:\r?\n)?)|(?P<inscribe_inline><!--\s*inscribe\s*-->`(?P<code_inline>[^`]+)`)"#
)
.unwrap()
});
static ATTR_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"(?P<key>\w+)="(?P<value>[^"]+)""#).unwrap());
pub fn process_markdown(
markdown_input: &str,
default_runners: &HashMap<String, Runner>,
input_path: Option<&Path>,
) -> Result<String, String> {
let mut batches: HashMap<Runner, Vec<&str>> = HashMap::new();
let mut active_runners = default_runners.clone();
let mut last_lang = String::new();
for caps in UNIFIED_RE.captures_iter(markdown_input) {
if let Some(def_text) = caps.name("runner_def") {
let lang = caps.name("runner_lang").unwrap().as_str().to_string();
let mut command = None;
let mut delimiter = None;
for attr_caps in ATTR_RE.captures_iter(def_text.as_str()) {
match attr_caps.name("key").unwrap().as_str() {
"command" => {
command = Some(attr_caps.name("value").unwrap().as_str().to_string())
}
"delimiter" => {
delimiter = Some(attr_caps.name("value").unwrap().as_str().to_string())
}
_ => {} }
}
let runner = Runner {
command: command.ok_or(format!("Runner for '{}' is missing a 'command'", lang))?,
delimiter_command: delimiter.unwrap_or_else(|| format!("echo {}", DELIMITER)),
};
active_runners.insert(lang, runner);
} else if caps.name("inscribe_block").is_some() || caps.name("inscribe_inline").is_some() {
let lang = if let Some(fenced_lang) = caps.name("lang_fenced") {
let l = fenced_lang.as_str().to_string();
last_lang = l.clone(); l
} else {
if last_lang.is_empty() {
return Err(
"Found an inline inscribe block before any language was specified.".into(),
);
}
last_lang.clone()
};
let code = caps
.name("code_fenced")
.or_else(|| caps.name("code_inline"))
.unwrap()
.as_str();
let runner = active_runners
.get(&lang)
.ok_or_else(|| format!("No runner configured for '{}'", lang))?;
batches.entry(runner.clone()).or_default().push(code);
}
}
let mut results_map: HashMap<Runner, Vec<String>> = HashMap::new();
for (runner, code_blocks) in batches {
let output = execute_batch(&runner, &code_blocks, input_path)?;
let results: Vec<String> = output.split(DELIMITER).map(|s| s.to_string()).collect();
results_map.insert(runner, results);
}
let mut final_output = String::new();
let mut last_end = 0;
let mut result_counters: HashMap<Runner, usize> = HashMap::new();
let mut active_runners = default_runners.clone(); let mut last_lang = String::new();
for caps in UNIFIED_RE.captures_iter(markdown_input) {
let match_start = caps.get(0).unwrap().start();
let match_end = caps.get(0).unwrap().end();
final_output.push_str(&markdown_input[last_end..match_start]);
if caps.name("runner_def").is_some() {
let lang = caps.name("runner_lang").unwrap().as_str().to_string();
let def_text = caps.name("runner_def").unwrap().as_str();
let mut command = None;
let mut delimiter = None;
for attr_caps in ATTR_RE.captures_iter(def_text) {
match attr_caps.name("key").unwrap().as_str() {
"command" => {
command = Some(attr_caps.name("value").unwrap().as_str().to_string())
}
"delimiter" => {
delimiter = Some(attr_caps.name("value").unwrap().as_str().to_string())
}
_ => {}
}
}
let runner = Runner {
command: command.unwrap(), delimiter_command: delimiter.unwrap_or_else(|| format!("echo {}", DELIMITER)),
};
active_runners.insert(lang, runner);
} else {
let is_inline = caps.name("inscribe_inline").is_some();
let lang = if let Some(fenced_lang) = caps.name("lang_fenced") {
let l = fenced_lang.as_str().to_string();
last_lang = l.clone();
l
} else {
last_lang.clone()
};
let runner = active_runners.get(&lang).unwrap();
let counter = result_counters.entry(runner.clone()).or_insert(0);
if let Some(results) = results_map.get(runner) {
if let Some(result) = results.get(*counter) {
if is_inline {
final_output.push_str(
result
.replace("\r\n", "\n")
.replace('\r', "\n")
.trim_matches('\n'),
);
} else {
let normalized_result = result.replace("\r\n", "\n").replace('\r', "\n");
let normalized_result = normalized_result
.trim_start_matches('\n')
.trim_start()
.trim_start_matches('\n');
if normalized_result.is_empty() || normalized_result.ends_with('\n') {
final_output.push_str(&normalized_result);
} else {
final_output.push_str(&normalized_result);
final_output.push('\n');
}
}
}
}
*counter += 1;
}
last_end = match_end;
}
final_output.push_str(&markdown_input[last_end..]);
Ok(final_output)
}
fn execute_batch(
runner: &Runner,
code_blocks: &[&str],
input_path: Option<&Path>,
) -> Result<String, String> {
let mut full_script = String::new();
for code_snippet in code_blocks {
full_script.push_str(code_snippet);
full_script.push('\n');
full_script.push_str(&runner.delimiter_command);
full_script.push('\n');
}
run_script_via_stdin(&runner.command, &full_script, input_path)
}
fn run_script_via_stdin(
runner_cmd: &str,
script_content: &str,
input_path: Option<&Path>,
) -> Result<String, String> {
let working_dir = match input_path.and_then(|p| p.parent()) {
Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
_ => env::current_dir().map_err(|e| e.to_string())?,
};
let cleaned_runner_cmd = runner_cmd.trim();
let mut parts = cleaned_runner_cmd.split_whitespace();
let command = parts.next().ok_or("Empty runner command")?;
let args: Vec<&str> = parts.collect();
let mut child = Command::new(command)
.args(&args)
.current_dir(working_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn command '{}': {}", command, e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(script_content.as_bytes())
.map_err(|e| format!("Failed to write to script stdin: {}", e))?;
}
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for command '{}': {}", command, e))?;
if !output.status.success() {
return Err(format!(
"Execution failed for command '{}'.\nStderr:\n{}",
command,
String::from_utf8_lossy(&output.stderr)
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}