Skip to content

Commit 87fef77

Browse files
authored
Merge pull request #363 from quarto-dev/bugfix/bd-ohvl879u-jupyter-engine-ignores-error
feat(jupyter,quarto-core): cell-options facility + error policy (bd-ohvl879u)
2 parents 7c42b4a + 65eb576 commit 87fef77

8 files changed

Lines changed: 1566 additions & 56 deletions

File tree

claude-notes/plans/2026-07-02-bd-ohvl879u-cell-options-error-policy.md

Lines changed: 478 additions & 0 deletions
Large diffs are not rendered by default.

crates/quarto-core/src/cell_options/mod.rs

Lines changed: 689 additions & 0 deletions
Large diffs are not rendered by default.

crates/quarto-core/src/engine/jupyter/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ pub enum JupyterError {
6464
traceback: Vec<String>,
6565
},
6666

67+
/// A code cell raised an error and error output is not allowed —
68+
/// no `#| error: true` on the cell and no `execute: error: true`
69+
/// on the document (bd-ohvl879u, matching knitr/Q1 policy).
70+
#[error("{message}")]
71+
CellExecutionFailed { message: String },
72+
73+
/// A cell's `#|` option lines are not valid YAML (bd-ohvl879u).
74+
#[error("{message}")]
75+
InvalidCellOptions { message: String },
76+
6777
/// Error receiving message from kernel.
6878
#[error("failed to receive message: {0}")]
6979
ReceiveError(String),

crates/quarto-core/src/engine/jupyter/text_execute.rs

Lines changed: 175 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@
1111
//! It parses QMD input, executes code blocks via the Jupyter daemon, and returns
1212
//! markdown with outputs inserted.
1313
14-
use std::path::PathBuf;
15-
1614
use regex::Regex;
1715

16+
use quarto_pandoc_types::config_value::{ConfigValue, InterpretationContext};
17+
use quarto_source_map::SourceInfo;
18+
1819
use super::daemon::daemon;
1920
use super::error::JupyterError;
2021
use super::execute::{CellOutput, ExecuteResult as KernelExecuteResult, ExecuteStatus};
2122
use super::session::SessionKey;
23+
use crate::cell_options::{merge_cell_over_scope, options_to_config, partition_cell_options};
2224
use crate::engine::context::{ExecuteResult, ExecutionContext};
2325
use crate::engine::error::ExecutionError;
2426

@@ -31,6 +33,9 @@ struct CodeBlock {
3133
start: usize,
3234
/// End byte offset in the input (exclusive).
3335
end: usize,
36+
/// Start byte offset of the code content (the capture between the
37+
/// fences) in the input — anchors per-cell source attribution.
38+
code_start: usize,
3439
/// The language/engine specifier (e.g., "python", "julia").
3540
language: String,
3641
/// The code content.
@@ -56,7 +61,7 @@ pub fn execute_qmd(
5661
let kernel_name = map_language_to_kernel(&blocks[0].language);
5762

5863
// Execute via async runtime
59-
let result = execute_blocks_async(input, &blocks, &kernel_name, &ctx.cwd);
64+
let result = execute_blocks_async(input, &blocks, &kernel_name, ctx);
6065

6166
result.map_err(|e| ExecutionError::execution_failed("jupyter", e.to_string()))
6267
}
@@ -95,13 +100,15 @@ fn parse_code_blocks(input: &str) -> Vec<CodeBlock> {
95100
for cap in re.captures_iter(input) {
96101
let full_match = cap.get(0).unwrap();
97102
let language = cap.get(1).unwrap().as_str().to_string();
98-
let code = cap.get(2).unwrap().as_str().to_string();
103+
let code_match = cap.get(2).unwrap();
104+
let code = code_match.as_str().to_string();
99105

100106
// Only include executable languages (not plain code blocks)
101107
if is_executable_language(&language) {
102108
blocks.push(CodeBlock {
103109
start: full_match.start(),
104110
end: full_match.end(),
111+
code_start: code_match.start(),
105112
language,
106113
code,
107114
});
@@ -137,35 +144,34 @@ fn execute_blocks_async(
137144
input: &str,
138145
blocks: &[CodeBlock],
139146
kernel_name: &str,
140-
working_dir: &PathBuf,
147+
ctx: &ExecutionContext,
141148
) -> JupyterResult<ExecuteResult> {
142149
// Use tokio runtime to execute async code
143150
let rt = tokio::runtime::Builder::new_current_thread()
144151
.enable_all()
145152
.build()
146153
.map_err(|e| JupyterError::RuntimeLibError(e.to_string()))?;
147154

148-
rt.block_on(execute_blocks_inner(
149-
input,
150-
blocks,
151-
kernel_name,
152-
working_dir,
153-
))
155+
rt.block_on(execute_blocks_inner(input, blocks, kernel_name, ctx))
154156
}
155157

156158
/// Inner async function that does the actual execution.
157159
async fn execute_blocks_inner(
158160
input: &str,
159161
blocks: &[CodeBlock],
160162
kernel_name: &str,
161-
working_dir: &PathBuf,
163+
ctx: &ExecutionContext,
162164
) -> JupyterResult<ExecuteResult> {
163165
let daemon = daemon();
164166

165167
// Start or get existing kernel session
166-
let key: SessionKey = daemon
167-
.get_or_start_session(kernel_name, working_dir)
168-
.await?;
168+
let key: SessionKey = daemon.get_or_start_session(kernel_name, &ctx.cwd).await?;
169+
170+
// Document-level defaults for cell options (bd-ohvl879u): the
171+
// input's front matter is the pipeline's fully merged metadata
172+
// (MetadataMergeStage runs before engine execution), so its
173+
// `execute` map is the scope cell options merge over.
174+
let doc_scope = document_execute_scope(input, ctx);
169175

170176
// Build output by processing blocks in order
171177
let mut output = String::new();
@@ -182,15 +188,55 @@ async fn execute_blocks_inner(
182188
output.push('\n');
183189
}
184190

185-
// Execute the code
191+
// Partition the cell into `#|` options + code (bd-ohvl879u).
192+
// `ctx.source_info` maps input offsets back to the original
193+
// files, so option spans and diagnostics resolve to real
194+
// source positions.
195+
let body_source = SourceInfo::substring(
196+
ctx.source_info.clone(),
197+
block.code_start,
198+
block.code_start + block.code.len(),
199+
);
200+
let cell = partition_cell_options(&block.language, &block.code, body_source.clone())
201+
.map_err(|e| {
202+
let at = e
203+
.location()
204+
.and_then(|loc| describe_location(loc, 0, ctx))
205+
.or_else(|| describe_location(&body_source, 0, ctx))
206+
.map(|l| format!(" at {l}"))
207+
.unwrap_or_default();
208+
JupyterError::InvalidCellOptions {
209+
message: format!("cell options are not valid YAML{at}: {e}"),
210+
}
211+
})?;
212+
let allow_errors = resolve_allow_errors(doc_scope.as_ref(), cell.options);
213+
214+
// Execute the code — the partitioned code only, without the
215+
// option lines (Q1 strips them too, so cell magics work).
186216
let exec_result = daemon
187-
.execute_in_session(&key, &block.code)
217+
.execute_in_session(&key, &cell.code)
188218
.await
189219
.ok_or(JupyterError::NotConnected)??;
190220

191-
// Emit the whole cell — echoed source plus outputs — in the
192-
// Quarto-canonical `::: {.cell}` shape.
193-
output.push_str(&render_cell(block, &exec_result));
221+
// Error policy (bd-ohvl879u, knitr/Q1 parity): a raising cell
222+
// aborts the render unless the cell or the document allows
223+
// errors in output.
224+
if !allow_errors && let Some((ename, evalue)) = first_cell_error(&exec_result) {
225+
let at = describe_location(&body_source, 0, ctx)
226+
.map(|l| format!(" at {l}"))
227+
.unwrap_or_default();
228+
return Err(JupyterError::CellExecutionFailed {
229+
message: format!(
230+
"code cell{at} raised {ename}: {evalue}\n\
231+
Use `#| error: true` on the cell (or `execute: error: true` in the \
232+
document metadata) to show the error in the output instead."
233+
),
234+
});
235+
}
236+
237+
// Emit the whole cell — echoed (option-stripped) source plus
238+
// outputs — in the Quarto-canonical `::: {.cell}` shape.
239+
output.push_str(&render_cell(&block.language, &cell.code, &exec_result));
194240

195241
last_end = block.end;
196242
}
@@ -201,6 +247,102 @@ async fn execute_blocks_inner(
201247
Ok(ExecuteResult::new(output))
202248
}
203249

250+
/// The `execute` scope of the document's (already merged) metadata,
251+
/// read from the engine input's front matter. `None` when the input
252+
/// has no front matter or no `execute` map.
253+
fn document_execute_scope(input: &str, ctx: &ExecutionContext) -> Option<ConfigValue> {
254+
let (start, end) = front_matter_range(input)?;
255+
let parent = SourceInfo::substring(ctx.source_info.clone(), start, end);
256+
let parsed = match quarto_yaml::parse_with_parent(&input[start..end], parent) {
257+
Ok(parsed) => parsed,
258+
Err(e) => {
259+
// The front matter was serialized by our own pipeline; a
260+
// parse failure here is an internal inconsistency, not a
261+
// user error — don't take the render down over defaults.
262+
tracing::warn!("engine input front matter did not re-parse: {e}");
263+
return None;
264+
}
265+
};
266+
let mut collector = pampa::utils::diagnostic_collector::DiagnosticCollector::new();
267+
let config = pampa::pandoc::meta::yaml_to_config_value(
268+
parsed,
269+
InterpretationContext::DocumentMetadata,
270+
&mut collector,
271+
);
272+
config.get("execute").cloned()
273+
}
274+
275+
/// Byte range of the YAML between the input's leading `---` fence and
276+
/// its closing `---` line (exclusive of both delimiter lines).
277+
fn front_matter_range(input: &str) -> Option<(usize, usize)> {
278+
let rest = input.strip_prefix("---")?;
279+
let rest = rest
280+
.strip_prefix("\r\n")
281+
.or_else(|| rest.strip_prefix('\n'))?;
282+
let fm_start = input.len() - rest.len();
283+
let mut search = 0;
284+
while let Some(pos) = rest[search..].find("\n---") {
285+
let abs = search + pos;
286+
let after = &rest[abs + 4..];
287+
if after.is_empty() || after.starts_with('\n') || after.starts_with('\r') {
288+
// Include the newline that ends the last YAML line.
289+
return Some((fm_start, fm_start + abs + 1));
290+
}
291+
search = abs + 4;
292+
}
293+
None
294+
}
295+
296+
/// Resolve the effective `error:` option for one cell: cell options
297+
/// merged over the document's `execute` scope (cell wins), absent
298+
/// everywhere = false (Q1's default `execute: error: false`).
299+
fn resolve_allow_errors(
300+
doc_scope: Option<&ConfigValue>,
301+
options: Option<quarto_yaml::YamlWithSourceInfo>,
302+
) -> bool {
303+
let cell_config = options.map(|o| {
304+
let (config, diagnostics) = options_to_config(o);
305+
for d in diagnostics {
306+
tracing::warn!("cell option conversion diagnostic: {d:?}");
307+
}
308+
config
309+
});
310+
merge_cell_over_scope(doc_scope, cell_config.as_ref())
311+
.as_ref()
312+
.and_then(|merged| merged.get("error"))
313+
.and_then(|v| v.as_bool())
314+
.unwrap_or(false)
315+
}
316+
317+
/// The first error a cell produced, from its outputs or its status.
318+
fn first_cell_error(result: &KernelExecuteResult) -> Option<(String, String)> {
319+
for output in &result.outputs {
320+
if let CellOutput::Error { ename, evalue, .. } = output {
321+
return Some((ename.clone(), evalue.clone()));
322+
}
323+
}
324+
if let ExecuteStatus::Error { ename, evalue, .. } = &result.status {
325+
return Some((ename.clone(), evalue.clone()));
326+
}
327+
None
328+
}
329+
330+
/// Render `info` + `offset` as `path:line:column` (1-based) through
331+
/// the execution context's source map, when it resolves.
332+
fn describe_location(info: &SourceInfo, offset: usize, ctx: &ExecutionContext) -> Option<String> {
333+
let mapped = info.map_offset(offset, &ctx.source_context)?;
334+
let path = ctx
335+
.source_context
336+
.get_file(mapped.file_id)
337+
.map(|f| f.path.clone())?;
338+
Some(format!(
339+
"{}:{}:{}",
340+
path,
341+
mapped.location.row + 1,
342+
mapped.location.column + 1
343+
))
344+
}
345+
204346
/// Fence for `text`, sized so the fence can never collide with the
205347
/// content: max(3, longest leading backtick run in `text` + 1)
206348
/// backticks. Mirrors Q1's `ticksForCode`
@@ -242,10 +384,10 @@ fn ticks_for_code(text: &str) -> String {
242384
/// engine cell with exactly one wrapper Div, and the Bootstrap CSS
243385
/// targets `.cell .cell-output-* pre code`. A cell with no outputs
244386
/// still gets the wrapper (knitr wraps output-less chunks too).
245-
fn render_cell(block: &CodeBlock, result: &KernelExecuteResult) -> String {
387+
fn render_cell(language: &str, code: &str, result: &KernelExecuteResult) -> String {
246388
format!(
247389
"::: {{.cell}}\n\n{}\n{}\n:::\n",
248-
echoed_source_fence(block),
390+
echoed_source_fence(language, code),
249391
format_outputs(result)
250392
)
251393
}
@@ -258,17 +400,14 @@ fn render_cell(block: &CodeBlock, result: &KernelExecuteResult) -> String {
258400
/// `{.python .cell-code}` — so the block is no longer scheduled for
259401
/// execution, the highlight stage resolves the language from the
260402
/// first class, and downstream consumers can target `.cell-code`
261-
/// (same classes knitr's hooks emit). Per-cell directives like
262-
/// `#| echo: false` travel inside `block.code` and are handled by
263-
/// whatever stage consumes them — this function only rewrites the
264-
/// fence.
265-
fn echoed_source_fence(block: &CodeBlock) -> String {
266-
let code = block.code.trim_end_matches('\n');
403+
/// (same classes knitr's hooks emit). The caller passes the
404+
/// *partitioned* code — `#|` option lines are consumed by
405+
/// `crate::cell_options` before execution and are not echoed
406+
/// (knitr/Q1 parity).
407+
fn echoed_source_fence(language: &str, code: &str) -> String {
408+
let code = code.trim_end_matches('\n');
267409
let ticks = ticks_for_code(code);
268-
format!(
269-
"{ticks}{{.{} .cell-code}}\n{}\n{ticks}",
270-
block.language, code
271-
)
410+
format!("{ticks}{{.{language} .cell-code}}\n{code}\n{ticks}")
272411
}
273412

274413
/// Wrap already-trimmed output text in a `::: {<classes>}` div around
@@ -526,15 +665,6 @@ print("hello")
526665
// purpose — this shape is a cross-engine contract (see the
527666
// engine_output_parity integration suite).
528667

529-
fn py_block(code: &str) -> CodeBlock {
530-
CodeBlock {
531-
start: 0,
532-
end: 0,
533-
language: "python".to_string(),
534-
code: code.to_string(),
535-
}
536-
}
537-
538668
fn ok_result(outputs: Vec<CellOutput>) -> KernelExecuteResult {
539669
KernelExecuteResult {
540670
status: ExecuteStatus::Ok,
@@ -555,9 +685,8 @@ print("hello")
555685
// source comes back as an attribute-form fence with the
556686
// language class first (the highlight stage resolves the
557687
// language from the first class) plus `.cell-code`.
558-
let block = py_block("print(\"hi\")\n");
559688
assert_eq!(
560-
echoed_source_fence(&block),
689+
echoed_source_fence("python", "print(\"hi\")\n"),
561690
"```{.python .cell-code}\nprint(\"hi\")\n```"
562691
);
563692
}
@@ -567,8 +696,7 @@ print("hello")
567696
// Q1's ticksForCode rule: max(3, longest leading backtick
568697
// run + 1). Code containing a ``` line must get a 4-tick
569698
// fence or the emitted markdown is corrupt.
570-
let block = py_block("s = \"\"\n```\n\"\"\n");
571-
let fence = echoed_source_fence(&block);
699+
let fence = echoed_source_fence("python", "s = \"\"\n```\n\"\"\n");
572700
assert!(
573701
fence.starts_with("````{.python .cell-code}\n"),
574702
"expected a 4-tick fence, got:\n{fence}"
@@ -658,14 +786,13 @@ print("hello")
658786

659787
#[test]
660788
fn test_render_cell_wraps_source_and_outputs_in_cell_div() {
661-
let block = py_block("2 + 3\n");
662789
let result = ok_result(vec![CellOutput::ExecuteResult {
663790
execution_count: 1,
664791
data: text_plain("5"),
665792
metadata: serde_json::json!({}),
666793
}]);
667794
assert_eq!(
668-
render_cell(&block, &result),
795+
render_cell("python", "2 + 3\n", &result),
669796
"::: {.cell}\n\n```{.python .cell-code}\n2 + 3\n```\n\n::: {.cell-output .cell-output-display}\n\n```\n5\n```\n\n:::\n\n:::\n"
670797
);
671798
}
@@ -732,10 +859,9 @@ print("hello")
732859
// gets the `.cell` wrapper — the splice must be able to
733860
// replace the live cell, and knitr wraps output-less chunks
734861
// too.
735-
let block = py_block("x = 1\n");
736862
let result = ok_result(vec![]);
737863
assert_eq!(
738-
render_cell(&block, &result),
864+
render_cell("python", "x = 1\n", &result),
739865
"::: {.cell}\n\n```{.python .cell-code}\nx = 1\n```\n\n:::\n"
740866
);
741867
}

crates/quarto-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
pub mod artifact;
4040
pub mod artifact_flush;
4141
pub mod attribution;
42+
pub mod cell_options;
4243
pub mod crossref;
4344
pub mod dependency;
4445
pub mod document_profile;

0 commit comments

Comments
 (0)