use super::block_provenance::{BlockProvenance, BlockProvenanceMap};
use super::{Block, DocumentProvenance, EngineDocument};
use crate::engine::EngineInput;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EvalOutput {
pub format: String,
pub text: String,
}
impl EvalOutput {
pub fn plain(text: impl Into<String>) -> Self {
Self {
format: "plain".into(),
text: text.into(),
}
}
pub fn detect(text: impl Into<String>) -> Self {
let text = text.into();
let gemtext = text.starts_with("# ")
|| text.starts_with("## ")
|| text.starts_with("### ")
|| text.starts_with("=> ")
|| text.contains("\n=> ");
Self {
format: if gemtext { "gemtext" } else { "plain" }.into(),
text,
}
}
}
pub trait BlockEvaluator {
fn language(&self) -> &str;
fn eval_block(&mut self, source: &str, max_ops: u64) -> Result<EvalOutput, String>;
}
#[derive(Default)]
pub struct BlockEvaluators {
by_language: std::collections::BTreeMap<String, Box<dyn BlockEvaluator>>,
}
impl BlockEvaluators {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, evaluator: Box<dyn BlockEvaluator>) -> &mut Self {
self.by_language
.insert(evaluator.language().to_string(), evaluator);
self
}
pub fn languages(&self) -> Vec<&str> {
self.by_language.keys().map(String::as_str).collect()
}
pub fn evaluate(
&mut self,
language: &str,
source: &str,
max_ops: u64,
) -> Result<EvalOutput, String> {
match self.by_language.get_mut(language) {
Some(evaluator) => evaluator.eval_block(source, max_ops),
None => Err(format!("no evaluator registered for `{language}`")),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EvaluationPolicy {
pub enabled: bool,
pub allowed_languages: Vec<String>,
}
impl EvaluationPolicy {
pub fn deny_all() -> Self {
Self {
enabled: false,
allowed_languages: Vec::new(),
}
}
pub fn for_own_notes(allowed_languages: Vec<String>) -> Self {
Self {
enabled: true,
allowed_languages,
}
}
}
#[derive(Debug, Default)]
pub struct EvalOutcome {
pub evaluated: usize,
pub denied: Vec<(String, String)>,
pub failed: Vec<(String, String)>,
pub provenance: BlockProvenanceMap,
}
pub fn parse_eval(language: &str) -> Option<&str> {
let mut tokens = language.split_whitespace();
let lang = tokens.next()?;
match tokens.next() {
Some("eval") if tokens.next().is_none() => Some(lang),
_ => None,
}
}
fn content_type_for(format: &str) -> &str {
match format {
"gemtext" | "gemini" => "text/gemini",
"markdown" | "md" => "text/markdown",
"djot" | "knot" => "text/x-knot",
other => other,
}
}
fn plain_block(text: &str) -> Block {
if text.contains('\n') {
Block::Preformatted {
text: text.to_string(),
}
} else {
Block::Paragraph {
spans: vec![super::InlineSpan::Text(text.to_string())],
}
}
}
pub fn evaluate_blocks(
document: &mut EngineDocument,
evaluate: &mut dyn FnMut(&str, &str) -> Result<EvalOutput, String>,
render: &mut dyn FnMut(&EngineInput) -> Result<EngineDocument, String>,
policy: &EvaluationPolicy,
) -> EvalOutcome {
let mut outcome = EvalOutcome::default();
let mut blocks: Vec<Block> = Vec::with_capacity(document.blocks.len());
for block in std::mem::take(&mut document.blocks) {
let fence = match &block {
Block::CodeBlock {
language: Some(language),
text,
} => parse_eval(language).map(|lang| (lang.to_string(), text.clone())),
_ => None,
};
let Some((lang, source)) = fence else {
blocks.push(block);
continue;
};
if !policy.enabled {
outcome.denied.push((lang, "evaluation disabled".into()));
blocks.push(block);
continue;
}
if !policy.allowed_languages.iter().any(|l| l == &lang) {
outcome
.denied
.push((lang, "language not in the allowlist".into()));
blocks.push(block);
continue;
}
let output = match evaluate(&lang, &source) {
Ok(output) => output,
Err(error) => {
outcome.failed.push((lang, error));
blocks.push(block);
continue;
}
};
let source_mark = BlockProvenance::from_document(DocumentProvenance {
source_label: Some(format!("evaluated:{lang}")),
..DocumentProvenance::default()
});
if output.format.is_empty() || output.format == "plain" || output.format == "text" {
outcome.provenance.insert(blocks.len(), source_mark);
blocks.push(plain_block(&output.text));
outcome.evaluated += 1;
continue;
}
let mut input = EngineInput::new(format!("eval:{lang}"), output.text);
input.content_type = Some(content_type_for(&output.format).to_string());
match render(&input) {
Ok(rendered) => {
for child in rendered.blocks {
outcome.provenance.insert(blocks.len(), source_mark.clone());
blocks.push(child);
}
outcome.evaluated += 1;
}
Err(error) => {
outcome
.failed
.push((lang, format!("render output: {error}")));
blocks.push(block);
}
}
}
document.blocks = blocks;
outcome
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{DocumentTrustState, InlineSpan};
fn doc_with(blocks: Vec<Block>) -> EngineDocument {
EngineDocument {
address: "test.knot".into(),
title: None,
content_type: "text/x-knot".into(),
lang: None,
provenance: DocumentProvenance::default(),
trust: DocumentTrustState::Unknown,
diagnostics: Vec::new(),
blocks,
}
}
fn eval_fence(lang_tag: &str, source: &str) -> Block {
Block::CodeBlock {
language: Some(lang_tag.to_string()),
text: source.to_string(),
}
}
fn stub_render(input: &EngineInput) -> Result<EngineDocument, String> {
Ok(doc_with(vec![Block::Paragraph {
spans: vec![InlineSpan::Text(format!(
"[{}] {}",
input.content_type.as_deref().unwrap_or("?"),
input.body
))],
}]))
}
fn allow_lua() -> EvaluationPolicy {
EvaluationPolicy::for_own_notes(vec!["lua".into()])
}
#[test]
fn parse_eval_requires_the_opt_in() {
assert_eq!(parse_eval("lua eval"), Some("lua"));
assert_eq!(parse_eval("lua"), None, "a plain fence is a sample");
assert_eq!(parse_eval("rust"), None);
assert_eq!(parse_eval("lua eval extra"), None);
assert_eq!(parse_eval(""), None);
}
#[test]
fn a_plain_result_renders_as_a_block() {
let mut document = doc_with(vec![eval_fence("lua eval", "return 1 + 1")]);
let mut evaluate = |_lang: &str, _src: &str| Ok(EvalOutput::plain("2"));
let outcome = evaluate_blocks(&mut document, &mut evaluate, &mut stub_render, &allow_lua());
assert_eq!(outcome.evaluated, 1);
assert!(matches!(
&document.blocks[0],
Block::Paragraph { spans } if spans == &vec![InlineSpan::Text("2".into())]
));
assert!(
outcome.provenance.get(0).is_some(),
"spliced block is marked generated"
);
}
#[test]
fn a_multiline_plain_result_is_preformatted() {
let mut document = doc_with(vec![eval_fence("lua eval", "x")]);
let mut evaluate = |_: &str, _: &str| Ok(EvalOutput::plain("line1\nline2"));
evaluate_blocks(&mut document, &mut evaluate, &mut stub_render, &allow_lua());
assert!(matches!(&document.blocks[0], Block::Preformatted { .. }));
}
#[test]
fn a_gemtext_result_is_nested_rendered() {
let mut document = doc_with(vec![eval_fence("lua eval", "x")]);
let mut evaluate = |_: &str, _: &str| {
Ok(EvalOutput {
format: "gemtext".into(),
text: "## generated".into(),
})
};
evaluate_blocks(&mut document, &mut evaluate, &mut stub_render, &allow_lua());
assert!(matches!(
&document.blocks[0],
Block::Paragraph { spans }
if spans == &vec![InlineSpan::Text("[text/gemini] ## generated".into())]
));
}
#[test]
fn disabled_and_disallowed_keep_the_source_fence() {
let mut document = doc_with(vec![eval_fence("lua eval", "return 1")]);
let mut evaluate =
|_: &str, _: &str| -> Result<EvalOutput, String> { panic!("must not run") };
let outcome = evaluate_blocks(
&mut document,
&mut evaluate,
&mut stub_render,
&EvaluationPolicy::deny_all(),
);
assert_eq!(outcome.evaluated, 0);
assert_eq!(outcome.denied.len(), 1);
assert!(matches!(&document.blocks[0], Block::CodeBlock { .. }));
let mut document = doc_with(vec![eval_fence("python eval", "print(1)")]);
let outcome = evaluate_blocks(&mut document, &mut evaluate, &mut stub_render, &allow_lua());
assert_eq!(outcome.denied.len(), 1);
assert!(outcome.denied[0].1.contains("allowlist"));
assert!(matches!(&document.blocks[0], Block::CodeBlock { .. }));
}
#[test]
fn a_failing_script_keeps_the_fence_and_reports() {
let mut document = doc_with(vec![eval_fence("lua eval", "error('boom')")]);
let mut evaluate =
|_: &str, _: &str| -> Result<EvalOutput, String> { Err("runtime error: boom".into()) };
let outcome = evaluate_blocks(&mut document, &mut evaluate, &mut stub_render, &allow_lua());
assert_eq!(outcome.failed.len(), 1);
assert!(matches!(&document.blocks[0], Block::CodeBlock { .. }));
}
#[test]
fn a_plain_lua_sample_fence_is_left_untouched() {
let mut document = doc_with(vec![eval_fence("lua", "this is a code sample")]);
let mut evaluate =
|_: &str, _: &str| -> Result<EvalOutput, String> { panic!("samples don't run") };
let outcome = evaluate_blocks(&mut document, &mut evaluate, &mut stub_render, &allow_lua());
assert_eq!(outcome.evaluated, 0);
assert!(outcome.denied.is_empty());
assert!(matches!(&document.blocks[0], Block::CodeBlock { .. }));
}
}