diff --git a/src/rmarkdown/chunks.ts b/src/rmarkdown/chunks.ts index 2493ad99b..68121a7dc 100644 --- a/src/rmarkdown/chunks.ts +++ b/src/rmarkdown/chunks.ts @@ -18,12 +18,17 @@ function isChunkStartLine(text: string, isRDoc: boolean) { } } -function isChunkEndLine(text: string, isRDoc: boolean) { +function fenceLength(text: string) { + return text.match(/^\s*(`{3,})/)?.[1].length ?? 0; +} + +function isChunkEndLine(text: string, isRDoc: boolean, minBackticks = 3) { if (isRDoc) { const isSectionHeader = text.match(/^#+\s*.*[-#+=*]{4,}/g); return isRChunkLine(text) || isSectionHeader; } else { - return !!text.match(/^\s*```+\s*$/g); + const match = text.match(/^\s*(`{3,})\s*$/); + return !!match && match[1].length >= minBackticks; } } @@ -97,6 +102,7 @@ export function getChunks(document: vscode.TextDocument): RMarkdownChunk[] { let line = 0; let chunkId = 0; // One-based index let chunkStartLine: number | undefined = undefined; + let chunkFenceLength = 0; let chunkLanguage: string | undefined = undefined; let chunkOptions: string | undefined = undefined; let chunkEval: boolean | undefined = undefined; @@ -130,12 +136,13 @@ export function getChunks(document: vscode.TextDocument): RMarkdownChunk[] { if (isChunkStartLine(lines[line], isRDoc)) { chunkId++; chunkStartLine = line; + chunkFenceLength = isRDoc ? 0 : fenceLength(lines[line]); chunkLanguage = getChunkLanguage(lines[line], isRDoc); chunkOptions = getChunkOptions(lines[line], isRDoc); chunkEval = getChunkEval(chunkOptions); } } else { - if (isChunkEndLine(lines[line], isRDoc)) { + if (isChunkEndLine(lines[line], isRDoc, chunkFenceLength)) { finishChunk(isRDoc ? line - 1 : line, line - 1); // An R marker ends the previous chunk and starts the next one, // including when the marker is the document's final line. diff --git a/src/test/suite/chunks.test.ts b/src/test/suite/chunks.test.ts index 3e5cfa4ae..75e455c1f 100644 --- a/src/test/suite/chunks.test.ts +++ b/src/test/suite/chunks.test.ts @@ -24,6 +24,44 @@ suite('R code chunk boundaries', () => { assert.deepStrictEqual(await chunkCode('```{r}\nx <- 1\n```', 'rmd'), ['x <- 1']); }); + test('keeps a longer outer fence open through a shorter inner example', async () => { + const text = '````{verbatim}\n\n```{r}\nfoo <- 1\n```\n\n````'; + const document = await vscode.workspace.openTextDocument({ + language: 'rmd', + content: text, + }); + const chunks = getChunks(document); + assert.strictEqual(chunks.length, 1); + assert.strictEqual(chunks[0].language, 'verbatim'); + assert.strictEqual(chunks[0].startLine, 0); + assert.strictEqual(chunks[0].endLine, 6); + assert.deepStrictEqual(document.getText(chunks[0].codeRange).split('\n'), [ + '', + '```{r}', + 'foo <- 1', + '```', + '', + ]); + }); + + test('ends a chunk on a longer fence and resumes with the next real chunk', async () => { + assert.deepStrictEqual(await chunkCode('```{r}\nx <- 1\n````', 'rmd'), ['x <- 1']); + const nestedThenReal = + '````{verbatim}\n\n```{r}\nfoo <- 1\n```\n\n````\n```{r}\nx <- 1\n```'; + assert.deepStrictEqual(await chunkCode(nestedThenReal, 'rmd'), [ + '\n```{r}\nfoo <- 1\n```\n', + 'x <- 1', + ]); + }); + + test('ignores info strings, bare fences, and unclosed R Markdown chunks', async () => { + assert.deepStrictEqual(await chunkCode('```{r}\nx <- 1\n``` label\n```', 'rmd'), [ + 'x <- 1\n``` label', + ]); + assert.deepStrictEqual(await chunkCode('```\ncode\n```', 'rmd'), []); + assert.deepStrictEqual(await chunkCode('```{r}\nx <- 1', 'rmd'), []); + }); + test('section headers end an R chunk without dropping its preceding line', async () => { assert.deepStrictEqual(await chunkCode('# %%\nx <- 1\n# section ----'), ['x <- 1']); });