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 => {
|
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 => {
|
||||||
|
|
|
||||||
|
|
@ -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)?,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 §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