Skip to content

Commit 5a21249

Browse files
authored
fix(lsp): parse #| cell-option block as one YAML document (#312)
Reassemble the leading #| run into a single YAML doc, highlight it once, and map the spans back per line — so multi-line YAML (block scalars, flow collections split across lines) highlights identically to a standalone YAML block, instead of the per-line approximation.
1 parent eb5c419 commit 5a21249

1 file changed

Lines changed: 129 additions & 10 deletions

File tree

crates/quarto-lsp-core/src/tokens.rs

Lines changed: 129 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
326339
fn 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\ncat(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\nx <- 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\nimport os\n```\n");

0 commit comments

Comments
 (0)