From 445177bbfac9b681f7e91a82519be29d2854cc1b Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 1 Oct 2026 10:51:39 +0200 Subject: [PATCH] Add execute ctl verb for org-babel style code execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - execute — all blocks in document - execute
— all blocks in subtree - execute
— single block by index - execute
— block by #+NAME in section - execute : — 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. --- crates/org-parser/src/convert.rs | 34 +++- crates/ork-cli/src/main.rs | 24 +++ crates/ork-server/src/state.rs | 306 ++++++++++++++++++++++++++++++- 3 files changed, 361 insertions(+), 3 deletions(-) diff --git a/crates/org-parser/src/convert.rs b/crates/org-parser/src/convert.rs index 9fc4b16..9da4b40 100644 --- a/crates/org-parser/src/convert.rs +++ b/crates/org-parser/src/convert.rs @@ -325,6 +325,29 @@ fn convert_element(source: &str, node: Node) -> ParseResult> { } 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> { 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> { preserve_indent: false, number_lines: None, }), - } + }; + + // Return early with affiliated keywords + return Ok(Some(Element { + span, + kind: element_kind, + affiliated, + })); } node_kinds::DRAWER => { diff --git a/crates/ork-cli/src/main.rs b/crates/ork-cli/src/main.rs index 93ae639..5be36aa 100644 --- a/crates/ork-cli/src/main.rs +++ b/crates/ork-cli/src/main.rs @@ -234,6 +234,16 @@ enum Commands { doc: Option, }, + /// Execute source code blocks + Execute { + /// Document name + doc: String, + /// Scope: section-id for subtree, :name for named block, omit for all + scope: Option, + /// Block: index or name within section (only with scope) + block: Option, + }, + /// 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)?, } diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index 2e906c3..a773d0f 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -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 [parent-id] — move subtree", OrkState::ctl_refile), ("archive", "archive — move subtree to .org_archive", OrkState::ctl_archive), ("lint", "lint [doc] — validate documents", OrkState::ctl_lint), + ("execute", "execute [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 [] [] + /// - 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 { + if args.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "usage: execute [] []", + )); + } + + 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::() { + // 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::>() + .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> { + 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> { + 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> { + 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 { + 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(), + }) + } } // ═══════════════════════════════════════════════════════════════════