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:
parent
b2953a7caf
commit
445177bbfa
|
|
@ -325,6 +325,29 @@ fn convert_element(source: &str, node: Node) -> ParseResult<Option<Element>> {
|
|||
}
|
||||
|
||||
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
|
||||
.child_by_field_name(field_names::NAME)
|
||||
.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(" "))
|
||||
};
|
||||
|
||||
match name.as_str() {
|
||||
let element_kind = match name.as_str() {
|
||||
"src" => ElementKind::SourceBlock(SourceBlock {
|
||||
language: params.first().cloned(),
|
||||
arguments,
|
||||
|
|
@ -362,7 +385,14 @@ fn convert_element(source: &str, node: Node) -> ParseResult<Option<Element>> {
|
|||
preserve_indent: false,
|
||||
number_lines: None,
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
// Return early with affiliated keywords
|
||||
return Ok(Some(Element {
|
||||
span,
|
||||
kind: element_kind,
|
||||
affiliated,
|
||||
}));
|
||||
}
|
||||
|
||||
node_kinds::DRAWER => {
|
||||
|
|
|
|||
|
|
@ -234,6 +234,16 @@ enum Commands {
|
|||
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)
|
||||
Query {
|
||||
/// 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))?,
|
||||
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)?,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
//! 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 parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{self, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Format a timestamp for display.
|
||||
|
|
@ -210,6 +211,7 @@ impl OrkState {
|
|||
("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),
|
||||
("lint", "lint [doc] — validate documents", OrkState::ctl_lint),
|
||||
("execute", "execute <doc> [scope] [block] — run src blocks", OrkState::ctl_execute),
|
||||
];
|
||||
|
||||
let cmd = cmd.trim();
|
||||
|
|
@ -296,6 +298,308 @@ impl OrkState {
|
|||
let target = args.first().copied();
|
||||
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 §ion.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 §ion.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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Reference in New Issue