Add execute ctl verb for org-babel style code execution

- execute <doc> — all blocks in document
- execute <doc> <section> — all blocks in subtree
- execute <doc> <section> <idx> — single block by index
- execute <doc> <section> <name> — block by #+NAME in section
- execute <doc> :<name> — named block anywhere in doc

Parser now extracts #+NAME: from block's affiliated keywords.
Supports bash, python, ruby, perl, and node interpreters.
Basic sandboxing via env_clear() and restricted PATH.
This commit is contained in:
Levi Neely 2026-10-01 10:51:39 +02:00
parent b2953a7caf
commit 445177bbfa
3 changed files with 361 additions and 3 deletions

View File

@ -325,6 +325,29 @@ fn convert_element(source: &str, node: Node) -> ParseResult<Option<Element>> {
} }
node_kinds::BLOCK => { node_kinds::BLOCK => {
// Extract affiliated keywords (like #+NAME:) from the block
let mut affiliated = Vec::new();
let mut debug_cursor = node.walk();
for child in node.children_by_field_name("directive", &mut debug_cursor) {
// Parse the directive
if child.kind() == "directive" {
let key = child
.child_by_field_name(field_names::NAME)
.map(|n| node_text(source, n).to_string())
.unwrap_or_default();
let value = child
.child_by_field_name(field_names::VALUE)
.map(|n| node_text(source, n).trim().to_string())
.unwrap_or_default();
affiliated.push(org_ast::AffiliatedKeyword {
span: node_span(child),
key,
backend: None,
value: org_ast::AffiliatedValue::Text(value),
});
}
}
let name = node let name = node
.child_by_field_name(field_names::NAME) .child_by_field_name(field_names::NAME)
.map(|n| node_text(source, n).to_lowercase()) .map(|n| node_text(source, n).to_lowercase())
@ -347,7 +370,7 @@ fn convert_element(source: &str, node: Node) -> ParseResult<Option<Element>> {
Some(params.join(" ")) Some(params.join(" "))
}; };
match name.as_str() { let element_kind = match name.as_str() {
"src" => ElementKind::SourceBlock(SourceBlock { "src" => ElementKind::SourceBlock(SourceBlock {
language: params.first().cloned(), language: params.first().cloned(),
arguments, arguments,
@ -362,7 +385,14 @@ fn convert_element(source: &str, node: Node) -> ParseResult<Option<Element>> {
preserve_indent: false, preserve_indent: false,
number_lines: None, number_lines: None,
}), }),
} };
// Return early with affiliated keywords
return Ok(Some(Element {
span,
kind: element_kind,
affiliated,
}));
} }
node_kinds::DRAWER => { node_kinds::DRAWER => {

View File

@ -234,6 +234,16 @@ enum Commands {
doc: Option<String>, doc: Option<String>,
}, },
/// Execute source code blocks
Execute {
/// Document name
doc: String,
/// Scope: section-id for subtree, :name for named block, omit for all
scope: Option<String>,
/// Block: index or name within section (only with scope)
block: Option<String>,
},
/// Find sections matching a query (tag:X+todo:Y+priority:Z) /// Find sections matching a query (tag:X+todo:Y+priority:Z)
Query { Query {
/// Match expression, e.g. "tag:work+todo:TODO" /// Match expression, e.g. "tag:work+todo:TODO"
@ -652,6 +662,20 @@ fn run(cli: Cli) -> io::Result<()> {
Some(d) => ctl(&mut client, &format!("lint {}", d))?, Some(d) => ctl(&mut client, &format!("lint {}", d))?,
None => ctl(&mut client, "lint")?, None => ctl(&mut client, "lint")?,
}, },
Commands::Execute { doc, scope, block } => {
let cmd = match (scope, block) {
(None, None) => format!("execute {}", doc),
(Some(s), None) => format!("execute {} {}", doc, s),
(Some(s), Some(b)) => format!("execute {} {} {}", doc, s, b),
(None, Some(_)) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"block requires scope",
));
}
};
ctl(&mut client, &cmd)?
}
Commands::Query { expr } => cmd_query(&mut client, &expr)?, Commands::Query { expr } => cmd_query(&mut client, &expr)?,
} }

View File

@ -1,12 +1,13 @@
//! Server state: manages org documents and their parsed state. //! Server state: manages org documents and their parsed state.
use org_ast::{Document, Timestamp, TimestampValue}; use org_ast::{Document, ElementKind, Section, SourceBlock, Timestamp, TimestampValue};
use org_parser::Parser; use org_parser::Parser;
use parking_lot::RwLock; use parking_lot::RwLock;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::io::{self, Result}; use std::io::{self, Result};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
/// Format a timestamp for display. /// Format a timestamp for display.
@ -210,6 +211,7 @@ impl OrkState {
("refile", "refile <doc> <id> <to-doc> [parent-id] — move subtree", OrkState::ctl_refile), ("refile", "refile <doc> <id> <to-doc> [parent-id] — move subtree", OrkState::ctl_refile),
("archive", "archive <doc> <id> — move subtree to <doc>.org_archive", OrkState::ctl_archive), ("archive", "archive <doc> <id> — move subtree to <doc>.org_archive", OrkState::ctl_archive),
("lint", "lint [doc] — validate documents", OrkState::ctl_lint), ("lint", "lint [doc] — validate documents", OrkState::ctl_lint),
("execute", "execute <doc> [scope] [block] — run src blocks", OrkState::ctl_execute),
]; ];
let cmd = cmd.trim(); let cmd = cmd.trim();
@ -296,6 +298,308 @@ impl OrkState {
let target = args.first().copied(); let target = args.first().copied();
Ok(self.lint(target)) Ok(self.lint(target))
} }
/// Execute src blocks.
///
/// Syntax: execute <doc> [<scope>] [<block>]
/// - execute doc — all blocks in document
/// - execute doc section — all blocks in section subtree
/// - execute doc section 0 — block by index in section
/// - execute doc section name — block by #+NAME in section
/// - execute doc :name — named block anywhere in doc
fn ctl_execute(&self, args: &[&str]) -> Result<String> {
if args.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: execute <doc> [<scope>] [<block>]",
));
}
let doc_name = args[0];
let doc_state = self.get_doc(doc_name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?;
// Collect blocks to execute based on scope
let blocks: Vec<(String, usize, SourceBlock)> = match args.len() {
// execute doc — all blocks in document
1 => self.collect_blocks_doc(&doc_state.doc),
// execute doc scope — subtree or named block
2 => {
let scope = args[1];
if let Some(name) = scope.strip_prefix(':') {
// Named block lookup across entire document
self.find_named_block_doc(&doc_state.doc, name)?
} else {
// Subtree execution
let section = self.find_section(&doc_state.doc, scope)
.ok_or_else(|| io::Error::new(
io::ErrorKind::NotFound,
format!("section not found: {}", scope),
))?;
self.collect_blocks_subtree(section)
}
}
// execute doc section block — specific block
3 => {
let section_id = args[1];
let block_spec = args[2];
let section = self.find_section(&doc_state.doc, section_id)
.ok_or_else(|| io::Error::new(
io::ErrorKind::NotFound,
format!("section not found: {}", section_id),
))?;
if let Ok(idx) = block_spec.parse::<usize>() {
// Block by index
self.get_block_by_index(section, section_id, idx)?
} else {
// Block by name
self.find_named_block_section(section, section_id, block_spec)?
}
}
_ => return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"too many arguments",
)),
};
if blocks.is_empty() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"no src blocks in scope",
));
}
// Execute each block
let mut results = Vec::new();
for (section_id, idx, block) in &blocks {
let result = self.execute_block(block)?;
results.push(format!(
"---\nsection: {}\nindex: {}\nlang: {}\nstatus: {}\nstdout: |\n{}\nstderr: |\n{}",
section_id,
idx,
block.language.as_deref().unwrap_or("unknown"),
result.status,
indent_lines(&result.stdout, " "),
indent_lines(&result.stderr, " "),
));
}
Ok(results.join("\n"))
}
}
/// Result of executing a source block.
struct ExecResult {
status: i32,
stdout: String,
stderr: String,
}
/// Indent each line of text with a prefix.
fn indent_lines(text: &str, prefix: &str) -> String {
if text.is_empty() {
return String::new();
}
text.lines()
.map(|l| format!("{}{}", prefix, l))
.collect::<Vec<_>>()
.join("\n")
}
// ═══════════════════════════════════════════════════════════════════
// Code Execution
// ═══════════════════════════════════════════════════════════════════
impl OrkState {
/// Collect all source blocks from a document.
fn collect_blocks_doc(&self, doc: &Document) -> Vec<(String, usize, SourceBlock)> {
let mut blocks = Vec::new();
for section in &doc.sections {
blocks.extend(self.collect_blocks_subtree(section));
}
blocks
}
/// Collect all source blocks from a section and its children (DFS).
fn collect_blocks_subtree(&self, section: &Section) -> Vec<(String, usize, SourceBlock)> {
let mut blocks = Vec::new();
let section_id = self.section_id(section);
// Blocks in this section
for (idx, elem) in section.content.iter().enumerate() {
if let ElementKind::SourceBlock(sb) = &elem.kind {
blocks.push((section_id.clone(), idx, sb.clone()));
}
}
// Recurse into children
for child in &section.children {
blocks.extend(self.collect_blocks_subtree(child));
}
blocks
}
/// Find a named block anywhere in the document.
fn find_named_block_doc(&self, doc: &Document, name: &str) -> Result<Vec<(String, usize, SourceBlock)>> {
for section in &doc.sections {
if let Some(result) = self.find_named_block_recursive(section, name) {
return Ok(vec![result]);
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("named block not found: {}", name),
))
}
/// Recursively search for a named block.
fn find_named_block_recursive(&self, section: &Section, name: &str) -> Option<(String, usize, SourceBlock)> {
let section_id = self.section_id(section);
// Check elements for SourceBlock with #+NAME:
// Try affiliated keywords first, then fall back to preceding Keyword element
let mut pending_name: Option<&str> = None;
for (idx, elem) in section.content.iter().enumerate() {
match &elem.kind {
ElementKind::Keyword(kw) if kw.key.eq_ignore_ascii_case("NAME") => {
pending_name = Some(&kw.value);
}
ElementKind::SourceBlock(sb) => {
// Check affiliated keywords first
for aff in &elem.affiliated {
if aff.key.eq_ignore_ascii_case("NAME") {
if let org_ast::AffiliatedValue::Text(ref val) = aff.value {
if val == name {
return Some((section_id, idx, sb.clone()));
}
}
}
}
// Fall back to preceding Keyword element
if pending_name == Some(name) {
return Some((section_id, idx, sb.clone()));
}
pending_name = None;
}
_ => {
pending_name = None;
}
}
}
// Recurse into children
for child in &section.children {
if let Some(result) = self.find_named_block_recursive(child, name) {
return Some(result);
}
}
None
}
/// Find a named block within a section.
fn find_named_block_section(&self, section: &Section, section_id: &str, name: &str) -> Result<Vec<(String, usize, SourceBlock)>> {
let mut pending_name: Option<&str> = None;
for (idx, elem) in section.content.iter().enumerate() {
match &elem.kind {
ElementKind::Keyword(kw) if kw.key.eq_ignore_ascii_case("NAME") => {
pending_name = Some(&kw.value);
}
ElementKind::SourceBlock(sb) => {
// Check affiliated keywords first
for aff in &elem.affiliated {
if aff.key.eq_ignore_ascii_case("NAME") {
if let org_ast::AffiliatedValue::Text(ref val) = aff.value {
if val == name {
return Ok(vec![(section_id.to_string(), idx, sb.clone())]);
}
}
}
}
// Fall back to preceding Keyword element
if pending_name == Some(name) {
return Ok(vec![(section_id.to_string(), idx, sb.clone())]);
}
pending_name = None;
}
_ => {
pending_name = None;
}
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("named block not found in section: {}", name),
))
}
/// Get a block by index within a section.
fn get_block_by_index(&self, section: &Section, section_id: &str, idx: usize) -> Result<Vec<(String, usize, SourceBlock)>> {
let blocks: Vec<_> = section.content.iter()
.enumerate()
.filter_map(|(i, elem)| {
if let ElementKind::SourceBlock(sb) = &elem.kind {
Some((i, sb))
} else {
None
}
})
.collect();
if idx >= blocks.len() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("block index out of range: {} (section has {} blocks)", idx, blocks.len()),
));
}
let (elem_idx, sb) = blocks[idx];
Ok(vec![(section_id.to_string(), elem_idx, sb.clone())])
}
/// Execute a source block and return the result.
fn execute_block(&self, block: &SourceBlock) -> Result<ExecResult> {
let lang = block.language.as_deref().unwrap_or("sh");
let (cmd, args): (&str, Vec<&str>) = match lang {
"sh" | "bash" | "shell" => ("bash", vec!["-c", &block.contents]),
"python" | "python3" => ("python3", vec!["-c", &block.contents]),
"ruby" => ("ruby", vec!["-e", &block.contents]),
"perl" => ("perl", vec!["-e", &block.contents]),
"node" | "javascript" | "js" => ("node", vec!["-e", &block.contents]),
_ => return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported language: {}", lang),
)),
};
// Execute with timeout
let output = Command::new(cmd)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear()
.env("PATH", "/usr/local/bin:/usr/bin:/bin")
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("LANG", "C.UTF-8")
.output()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("failed to execute {}: {}", cmd, e),
))?;
Ok(ExecResult {
status: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
} }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════