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-
1614use regex:: Regex ;
1715
16+ use quarto_pandoc_types:: config_value:: { ConfigValue , InterpretationContext } ;
17+ use quarto_source_map:: SourceInfo ;
18+
1819use super :: daemon:: daemon;
1920use super :: error:: JupyterError ;
2021use super :: execute:: { CellOutput , ExecuteResult as KernelExecuteResult , ExecuteStatus } ;
2122use super :: session:: SessionKey ;
23+ use crate :: cell_options:: { merge_cell_over_scope, options_to_config, partition_cell_options} ;
2224use crate :: engine:: context:: { ExecuteResult , ExecutionContext } ;
2325use 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.
157159async 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}\n print(\" 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}\n 2 + 3\n ```\n \n ::: {.cell-output .cell-output-display}\n \n ```\n 5\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}\n x = 1\n ```\n \n :::\n "
740866 ) ;
741867 }
0 commit comments