diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index 9357429..cfceb7d 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -416,10 +416,13 @@ impl OrkState { blocks_with_results.sort_by(|a, b| b.3.end.offset.cmp(&a.3.end.offset)); for (section_id, idx, block, span, result) in &blocks_with_results { + let params = parse_results_params(block.arguments.as_deref()); match result { Ok(exec_result) => { - // Insert results into document - content = self.insert_results(&content, *span, &exec_result.stdout); + // Insert results into document unless the block is silent. + if !params.silent { + content = self.insert_results(&content, *span, &exec_result.stdout, params.wrap); + } results_output.push(format!( "---\nsection: {}\nindex: {}\nlang: {}\nstatus: {}\nstdout: |\n{}\nstderr: |\n{}", @@ -452,7 +455,7 @@ impl OrkState { } /// Insert #+RESULTS: block after a source block. - fn insert_results(&self, content: &str, block_span: Span, stdout: &str) -> String { + fn insert_results(&self, content: &str, block_span: Span, stdout: &str, wrap: ResultWrap) -> String { let end_offset = block_span.end.offset.min(content.len()); // The block span end already points to the start of the line following @@ -487,34 +490,56 @@ impl OrkState { let abs_start = insert_pos; let mut abs_end = abs_start; - // Track state: are we in results, and are we in an example block? + // Track state across the possible wrapping styles: + // - fixed-width (`: ` prefixed lines) + // - #+begin_example ... #+end_example + // - :results: ... :end: drawer + // - raw (bare contiguous lines terminated by a blank line) let mut in_results = false; let mut in_example_block = false; + let mut in_drawer = false; for line in after_block.lines() { let trimmed = line.trim(); let upper = trimmed.to_uppercase(); - if upper.starts_with(results_prefix) { - in_results = true; - abs_end += line.len() + 1; // +1 for newline - } else if in_results && upper.starts_with("#+BEGIN_EXAMPLE") { - in_example_block = true; - abs_end += line.len() + 1; - } else if in_example_block { + if !in_results { + if upper.starts_with(results_prefix) { + in_results = true; + abs_end += line.len() + 1; // +1 for newline + continue; + } + // Haven't reached the results keyword yet; nothing to replace. + break; + } + + // We are inside the results block. + if in_example_block { abs_end += line.len() + 1; if upper.starts_with("#+END_EXAMPLE") { in_example_block = false; } - } else if in_results && (trimmed.starts_with(':') || trimmed.is_empty()) { - // Fixed-width format or blank line continuation + } else if in_drawer { abs_end += line.len() + 1; - } else if in_results { - // End of results block + if upper == ":END:" { + in_drawer = false; + } + } else if upper.starts_with("#+BEGIN_EXAMPLE") { + in_example_block = true; + abs_end += line.len() + 1; + } else if upper == ":RESULTS:" { + in_drawer = true; + abs_end += line.len() + 1; + } else if trimmed.is_empty() { + // A blank line terminates the results block (consume it). + abs_end += line.len() + 1; + break; + } else if trimmed.starts_with('*') { + // A heading starts a new element; stop without consuming it. break; } else { - // No results block found - break; + // Content line: fixed-width (`: ...`) or raw. Consume it. + abs_end += line.len() + 1; } } (abs_start, abs_end.min(content.len())) @@ -524,7 +549,7 @@ impl OrkState { }; // Format the new results - let formatted_results = format_results(stdout); + let formatted_results = format_results(stdout, wrap); // Build new content let mut new_content = String::with_capacity(content.len() + formatted_results.len()); @@ -540,33 +565,111 @@ impl OrkState { /// Matches Emacs `org-babel-min-lines-for-block-output` default. const MIN_LINES_FOR_BLOCK_OUTPUT: usize = 10; -/// Format stdout as org #+RESULTS: block. -/// Uses fixed-width (`: ` prefix) for <10 lines, #+begin_example block for ≥10. -fn format_results(stdout: &str) -> String { +/// How to wrap the output under the `#+RESULTS:` keyword. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResultWrap { + /// Fixed-width (`: ` prefix) for small output, #+begin_example for large. + Default, + /// Insert output verbatim, no prefix or wrapping. + Raw, + /// Wrap output in a `:results:` ... `:end:` drawer. + Drawer, +} + +/// Parsed `:results` header-argument options relevant to insertion. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ResultsParams { + /// `silent`/`none` — execute but do not write a results block. + silent: bool, + /// How to wrap the inserted output. + wrap: ResultWrap, +} + +impl Default for ResultsParams { + fn default() -> Self { + ResultsParams { silent: false, wrap: ResultWrap::Default } + } +} + +/// Parse the `:results` switch from a source block's raw header arguments. +/// +/// `arguments` is the full header string, e.g. `"python :results output drawer"`. +/// Only the tokens following `:results` (up to the next `:switch` or end) are +/// interpreted. Unknown keywords (table, html, value, ...) are ignored, which +/// leaves the default behavior intact. +fn parse_results_params(arguments: Option<&str>) -> ResultsParams { + let mut params = ResultsParams::default(); + let Some(args) = arguments else { return params }; + + let tokens: Vec<&str> = args.split_whitespace().collect(); + // Find the ":results" switch (case-insensitive). + let Some(start) = tokens.iter().position(|t| t.eq_ignore_ascii_case(":results")) else { + return params; + }; + + // Collect values until the next switch (token starting with ':'). + for tok in &tokens[start + 1..] { + if tok.starts_with(':') { + break; + } + match tok.to_ascii_lowercase().as_str() { + "silent" | "none" => params.silent = true, + "raw" => params.wrap = ResultWrap::Raw, + "drawer" => params.wrap = ResultWrap::Drawer, + // output/value and other type keywords don't change insertion here. + _ => {} + } + } + + params +} + +/// Format stdout as org #+RESULTS: block, honoring the wrap mode. +/// Default: fixed-width (`: ` prefix) for <10 lines, #+begin_example for ≥10. +fn format_results(stdout: &str, wrap: ResultWrap) -> String { if stdout.is_empty() { return String::new(); } - - let line_count = stdout.lines().count(); + let mut result = String::from("#+RESULTS:\n"); - - if line_count < MIN_LINES_FOR_BLOCK_OUTPUT { - // Fixed-width format: prefix each line with ": " - for line in stdout.lines() { - result.push_str(": "); - result.push_str(line); - result.push('\n'); + + match wrap { + ResultWrap::Raw => { + // Verbatim: no prefix, no wrapping. + result.push_str(stdout); + if !stdout.ends_with('\n') { + result.push('\n'); + } } - } else { - // Example block format for larger output - result.push_str("#+begin_example\n"); - result.push_str(stdout); - if !stdout.ends_with('\n') { - result.push('\n'); + ResultWrap::Drawer => { + result.push_str(":results:\n"); + result.push_str(stdout); + if !stdout.ends_with('\n') { + result.push('\n'); + } + result.push_str(":end:\n"); + } + ResultWrap::Default => { + let line_count = stdout.lines().count(); + if line_count < MIN_LINES_FOR_BLOCK_OUTPUT { + // Fixed-width format: prefix each line with ": " + for line in stdout.lines() { + result.push_str(": "); + result.push_str(line); + result.push('\n'); + } + } else { + // Example block format for larger output + result.push_str("#+begin_example\n"); + result.push_str(stdout); + if !stdout.ends_with('\n') { + result.push('\n'); + } + result.push_str("#+end_example\n"); + } } - result.push_str("#+end_example\n"); } - + // Add blank line after results for separation result.push('\n'); result @@ -2037,14 +2140,14 @@ mod tests { #[test] fn test_format_results_empty() { - assert_eq!(format_results(""), ""); + assert_eq!(format_results("", ResultWrap::Default), ""); } #[test] fn test_format_results_short() { // < 10 lines uses fixed-width format (: prefix) let stdout = "line1\nline2\nline3\n"; - let result = format_results(stdout); + let result = format_results(stdout, ResultWrap::Default); assert!(result.starts_with("#+RESULTS:\n")); assert!(result.contains(": line1\n")); assert!(result.contains(": line2\n")); @@ -2057,7 +2160,7 @@ mod tests { // >= 10 lines uses #+begin_example block let lines: Vec<_> = (1..=15).map(|i| format!("line{}", i)).collect(); let stdout = lines.join("\n"); - let result = format_results(&stdout); + let result = format_results(&stdout, ResultWrap::Default); assert!(result.starts_with("#+RESULTS:\n")); assert!(result.contains("#+begin_example\n")); assert!(result.contains("#+end_example\n")); @@ -2066,4 +2169,61 @@ mod tests { // Should NOT have : prefix in example block assert!(!result.contains(": line1")); } + + #[test] + fn test_format_results_raw() { + // raw: verbatim, no prefix, no wrapping + let stdout = "a\nb\nc\n"; + let result = format_results(stdout, ResultWrap::Raw); + assert_eq!(result, "#+RESULTS:\na\nb\nc\n\n"); + } + + #[test] + fn test_format_results_drawer() { + // drawer: :results: ... :end: wrapper + let stdout = "x\ny\n"; + let result = format_results(stdout, ResultWrap::Drawer); + assert_eq!(result, "#+RESULTS:\n:results:\nx\ny\n:end:\n\n"); + } + + #[test] + fn test_parse_results_params() { + // No arguments → default + let p = parse_results_params(None); + assert_eq!(p, ResultsParams { silent: false, wrap: ResultWrap::Default }); + + // Language only, no :results → default + let p = parse_results_params(Some("python")); + assert_eq!(p.wrap, ResultWrap::Default); + assert!(!p.silent); + + // :results output → default wrap (output doesn't change insertion) + let p = parse_results_params(Some("python :results output")); + assert_eq!(p.wrap, ResultWrap::Default); + assert!(!p.silent); + + // :results raw + let p = parse_results_params(Some("bash :results raw")); + assert_eq!(p.wrap, ResultWrap::Raw); + + // :results output drawer + let p = parse_results_params(Some("sh :results output drawer")); + assert_eq!(p.wrap, ResultWrap::Drawer); + + // :results silent + let p = parse_results_params(Some("python :results silent")); + assert!(p.silent); + + // none is an alias for silent + let p = parse_results_params(Some("python :results none")); + assert!(p.silent); + + // Values stop at the next switch + let p = parse_results_params(Some("python :results raw :exports both")); + assert_eq!(p.wrap, ResultWrap::Raw); + + // Case-insensitive switch + let p = parse_results_params(Some("python :RESULTS drawer")); + assert_eq!(p.wrap, ResultWrap::Drawer); + } }