@@ -320,14 +320,32 @@ fn leading_directive_byte_len(body: &str) -> usize {
320320 end
321321}
322322
323+ /// Where one `#|` line's YAML content lives in both coordinate systems: its
324+ /// byte offset within the reconstructed yaml document and within the real
325+ /// document, plus its length. Used to map highlight spans back.
326+ struct DirectiveLine {
327+ virt_start : usize ,
328+ doc_start : usize ,
329+ len : usize ,
330+ }
331+
323332/// Tokenize a cell-option block (`segment`, starting at byte `seg_start`): the
324- /// `#|` marker on each line is a comment; the YAML after it is highlighted
325- /// per line with the yaml grammar.
333+ /// `#|` marker on each line is a comment, and the YAML after it is highlighted
334+ /// as a **single document** — the per-line content (with the `#|` prefix
335+ /// stripped, indentation preserved) is reassembled, highlighted once, and the
336+ /// spans mapped back into document coordinates. Parsing the whole block as one
337+ /// document is what makes multi-line YAML (block scalars, flow collections
338+ /// split across lines) highlight correctly.
326339fn directive_tokens ( segment : & str , seg_start : usize ) -> Vec < ByteToken > {
327340 let comment_tt = capture_to_token_type ( "comment" , true ) ;
328341 let mut out = Vec :: new ( ) ;
342+
343+ // Pass 1: emit `#|` markers and reconstruct the YAML document, recording
344+ // each line's content position in both the virtual and real documents.
345+ let mut yaml_doc = String :: with_capacity ( segment. len ( ) ) ;
346+ let mut lines: Vec < DirectiveLine > = Vec :: new ( ) ;
329347 let mut pos = seg_start;
330- for line in segment. split_inclusive ( '\n' ) {
348+ for ( i , line) in segment. split_inclusive ( '\n' ) . enumerate ( ) {
331349 let mut line_body = line. strip_suffix ( '\n' ) . unwrap_or ( line) ;
332350 line_body = line_body. strip_suffix ( '\r' ) . unwrap_or ( line_body) ;
333351
@@ -340,20 +358,56 @@ fn directive_tokens(segment: &str, seg_start: usize) -> Vec<ByteToken> {
340358 token_type : tt,
341359 } ) ;
342360 }
343- // YAML content begins after `#|` and an optional single space.
361+ // YAML content begins after `#|` and an optional single space; the
362+ // remaining indentation is kept so nesting survives reassembly.
344363 let mut content_col = indent + 2 ;
345364 if line_body. as_bytes ( ) . get ( content_col) == Some ( & b' ' ) {
346365 content_col += 1 ;
347366 }
348- if content_col < line_body. len ( ) {
349- out. extend ( embedded_body_tokens (
350- "yaml" ,
351- & line_body[ content_col..] ,
352- pos + content_col,
353- ) ) ;
367+ let content = & line_body[ content_col. min ( line_body. len ( ) ) ..] ;
368+
369+ // One virtual line per directive line (preserve blank lines exactly).
370+ if i > 0 {
371+ yaml_doc. push ( '\n' ) ;
354372 }
373+ lines. push ( DirectiveLine {
374+ virt_start : yaml_doc. len ( ) ,
375+ doc_start : pos + content_col,
376+ len : content. len ( ) ,
377+ } ) ;
378+ yaml_doc. push_str ( content) ;
379+
355380 pos += line. len ( ) ;
356381 }
382+
383+ // Pass 2: highlight the reassembled block once, then map each span back,
384+ // splitting it across the lines it touches (a multi-line YAML span becomes
385+ // one token per document line; the synthetic `\n` joins are never coloured).
386+ let Ok ( Some ( spans) ) = quarto_highlight:: highlight_captures ( "yaml" , & yaml_doc) else {
387+ return out;
388+ } ;
389+ for s in flatten_spans ( spans) {
390+ let Some ( tt) = capture_to_token_type ( & s. capture , true ) else {
391+ continue ;
392+ } ;
393+ for line in & lines {
394+ // `lines` is sorted by `virt_start`, so once a line starts at or
395+ // after the span's end, no later line can overlap it.
396+ if line. virt_start >= s. end {
397+ break ;
398+ }
399+ let line_end = line. virt_start + line. len ;
400+ let start = s. start . max ( line. virt_start ) ;
401+ let end = s. end . min ( line_end) ;
402+ if end > start {
403+ out. push ( ByteToken {
404+ start : line. doc_start + ( start - line. virt_start ) ,
405+ end : line. doc_start + ( end - line. virt_start ) ,
406+ token_type : tt,
407+ } ) ;
408+ }
409+ }
410+ }
357411 out
358412}
359413
@@ -844,6 +898,71 @@ mod tests {
844898 ) ;
845899 }
846900
901+ #[ test]
902+ fn cell_option_multiline_flow_sequence_parsed_as_block ( ) {
903+ // The whole `#|` block is parsed as one YAML document, so a flow
904+ // sequence split across lines is highlighted correctly — not as the
905+ // per-line plain scalars a line-at-a-time parse would produce.
906+ let src = "```{r}\n #| also_array: [\n #| 1, 2, 3\n #| ]\n #| echo: true\n cat(1)\n ```\n " ;
907+ let toks = tokens ( src) ;
908+ // The key before the across-lines flow sequence stays a property.
909+ assert ! (
910+ toks. iter( )
911+ . any( |t| t. line == 1 && type_name( t) == "qmd.code.property" ) ,
912+ "expected `also_array` as property on line 1, got {toks:?}"
913+ ) ;
914+ // The continuation line's elements are three numbers, not one string.
915+ assert_eq ! (
916+ toks. iter( )
917+ . filter( |t| t. line == 2 && type_name( t) == "qmd.code.number" )
918+ . count( ) ,
919+ 3 ,
920+ "expected 3 numbers on the flow continuation line, got {toks:?}"
921+ ) ;
922+ assert ! (
923+ !toks
924+ . iter( )
925+ . any( |t| t. line == 2 && type_name( t) == "qmd.code.string" ) ,
926+ "flow elements must not be a plain-scalar string, got {toks:?}"
927+ ) ;
928+ // The closing bracket on its own line is punctuation.
929+ assert ! (
930+ toks. iter( )
931+ . any( |t| t. line == 3 && type_name( t) == "qmd.code.punctuation" ) ,
932+ "expected `]` as punctuation on line 3, got {toks:?}"
933+ ) ;
934+ // Subsequent option and code are still correct.
935+ assert ! (
936+ toks. iter( )
937+ . any( |t| t. line == 4 && type_name( t) == "qmd.code.boolean" ) ,
938+ "expected `echo: true` boolean on line 4, got {toks:?}"
939+ ) ;
940+ assert ! ( line_has_code_type( & toks, 5 ) , "expected r code on line 5" ) ;
941+ }
942+
943+ #[ test]
944+ fn cell_option_block_scalar_body_is_string_per_line ( ) {
945+ // A `>` block scalar whose body spans multiple `#|` lines: each body
946+ // line is a string token (the multi-line YAML span is split per line).
947+ let src = "```{r}\n #| desc: >\n #| line one\n #| line two\n x <- 1\n ```\n " ;
948+ let toks = tokens ( src) ;
949+ assert ! (
950+ toks. iter( )
951+ . any( |t| t. line == 1 && type_name( t) == "qmd.code.property" ) ,
952+ "expected `desc` property on line 1, got {toks:?}"
953+ ) ;
954+ assert ! (
955+ toks. iter( )
956+ . any( |t| t. line == 2 && type_name( t) == "qmd.code.string" ) ,
957+ "expected block-scalar body string on line 2, got {toks:?}"
958+ ) ;
959+ assert ! (
960+ toks. iter( )
961+ . any( |t| t. line == 3 && type_name( t) == "qmd.code.string" ) ,
962+ "expected block-scalar body string on line 3, got {toks:?}"
963+ ) ;
964+ }
965+
847966 #[ test]
848967 fn tokens_for_fenced_python ( ) {
849968 let toks = tokens ( "```python\n import os\n ```\n " ) ;
0 commit comments