From 23dd5039ae8732e668d9b5980cc0cf765c3fbfad Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 11 Mar 2026 17:05:18 -0400 Subject: [PATCH 1/5] More accurately type functions --- apps/vscode/src/providers/format.ts | 62 ++++++++++++++++------------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/apps/vscode/src/providers/format.ts b/apps/vscode/src/providers/format.ts index a8a3663d..99f71a31 100644 --- a/apps/vscode/src/providers/format.ts +++ b/apps/vscode/src/providers/format.ts @@ -182,7 +182,7 @@ async function executeFormatDocumentProvider( options: FormattingOptions ): Promise { const edits = await withVirtualDocUri(vdoc, document.uri, "format", async (uri: Uri) => { - return await commands.executeCommand( + return await commands.executeCommand( "vscode.executeFormatDocumentProvider", uri, options @@ -195,7 +195,7 @@ async function executeFormatDocumentProvider( } } -async function formatActiveCell(editor: TextEditor, engine: MarkdownEngine) { +async function formatActiveCell(editor: TextEditor, engine: MarkdownEngine): Promise { const doc = editor?.document; const tokens = engine.parse(doc); const line = editor.selection.start.line; @@ -204,10 +204,12 @@ async function formatActiveCell(editor: TextEditor, engine: MarkdownEngine) { const block = languageBlockAtPosition(tokens, position, false); if (language?.canFormat && block) { return formatBlock(doc, block, language); + } else { + return undefined; } } -async function formatBlock(doc: TextDocument, block: TokenMath | TokenCodeBlock, language: EmbeddedLanguage) { +async function formatBlock(doc: TextDocument, block: TokenMath | TokenCodeBlock, language: EmbeddedLanguage): Promise { // Create virtual document containing the block const blockLines = lines(codeForExecutableLanguageBlock(block, false)); const vdoc = virtualDocForCode(blockLines, language); @@ -218,35 +220,39 @@ async function formatBlock(doc: TextDocument, block: TokenMath | TokenCodeBlock, formattingOptions(doc.uri, vdoc.language) ); - if (edits) { - // Because we format with the block code copied in an empty virtual - // document, we need to adjust the ranges to match the edits to the block - // cell in the original file. - const blockRange = new Range( - new Position(block.range.start.line, block.range.start.character), - new Position(block.range.end.line, block.range.end.character) - ); - const adjustedEdits = edits - .map(edit => { - const range = new Range( - new Position(edit.range.start.line + block.range.start.line + 1, edit.range.start.character), - new Position(edit.range.end.line + block.range.start.line + 1, edit.range.end.character) - ); - return new TextEdit(range, edit.newText); - }); + if (!edits) { + // Either no formatter picked us up, or there were no edits required. + // We can't determine the difference though! + return undefined; + } - // Bail if any edit is out of range. We used to filter these edits out but - // this could bork the cell. - if (adjustedEdits.some(edit => !blockRange.contains(edit.range))) { - window.showInformationMessage( - "Formatting edits were out of range and could not be applied to the code cell." + // Because we format with the block code copied in an empty virtual + // document, we need to adjust the ranges to match the edits to the block + // cell in the original file. + const blockRange = new Range( + new Position(block.range.start.line, block.range.start.character), + new Position(block.range.end.line, block.range.end.character) + ); + const adjustedEdits = edits + .map(edit => { + const range = new Range( + new Position(edit.range.start.line + block.range.start.line + 1, edit.range.start.character), + new Position(edit.range.end.line + block.range.start.line + 1, edit.range.end.character) ); - return []; - } + return new TextEdit(range, edit.newText); + }); - return adjustedEdits; + // Bail if any edit is out of range. We used to filter these edits out but + // this could bork the cell. Return `[]` to indicate that we tried. + if (adjustedEdits.some(edit => !blockRange.contains(edit.range))) { + window.showInformationMessage( + "Formatting edits were out of range and could not be applied to the code cell." + ); + return []; } -} + + return adjustedEdits; +}; function unadjustedEdits( From a4274160bb889d8b890580ff387053fcf5639693 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 11 Mar 2026 17:06:24 -0400 Subject: [PATCH 2/5] Remove message about not being in a formattable cell We can't actually tell the difference between a cell that doesn't have a formatter and a cell with a formatter that said "there is nothing to do, it's already formatted" --- apps/vscode/src/providers/format.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/vscode/src/providers/format.ts b/apps/vscode/src/providers/format.ts index 99f71a31..ce673f27 100644 --- a/apps/vscode/src/providers/format.ts +++ b/apps/vscode/src/providers/format.ts @@ -146,10 +146,6 @@ class FormatCellCommand implements Command { editBuilder.replace(edit.range, edit.newText); }); }); - } else { - window.showInformationMessage( - "Editor selection is not within a code cell that supports formatting." - ); } } else { window.showInformationMessage("Active editor is not a Quarto document"); From bbd7e903e8c0163208538a47c88b48c893f8e763 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 11 Mar 2026 17:10:46 -0400 Subject: [PATCH 3/5] CHANGELOG bullet --- apps/vscode/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index b42083b6..f7f27bb3 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.131.0 (Unreleased) - +- Fixed a bug where `Quarto: Format Cell` would notify you that no formatter was available for code cells that were already formatted (). ## 1.130.0 (Release on 2026-02-18) From e2a406a2818985ba7286966c9f25b41d5bd86a39 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Fri, 13 Mar 2026 11:13:46 -0400 Subject: [PATCH 4/5] Dramatically improve readability --- apps/vscode/src/providers/format.ts | 202 +++++++++++------- packages/quarto-core/src/markdown/language.ts | 16 +- 2 files changed, 132 insertions(+), 86 deletions(-) diff --git a/apps/vscode/src/providers/format.ts b/apps/vscode/src/providers/format.ts index ce673f27..73cbd3b3 100644 --- a/apps/vscode/src/providers/format.ts +++ b/apps/vscode/src/providers/format.ts @@ -24,21 +24,20 @@ import { workspace, CancellationToken, Uri, - TextEditor, } from "vscode"; import { ProvideDocumentFormattingEditsSignature, ProvideDocumentRangeFormattingEditsSignature, } from "vscode-languageclient/node"; import { lines } from "core"; -import { TokenCodeBlock, TokenMath, codeForExecutableLanguageBlock, languageBlockAtPosition } from "quarto-core"; +import { TokenCodeBlock, TokenMath, codeForExecutableLanguageBlock, languageBlockAtLine } from "quarto-core"; import { Command } from "../core/command"; import { isQuartoDoc } from "../core/doc"; import { MarkdownEngine } from "../markdown/engine"; import { EmbeddedLanguage, languageCanFormatDocument } from "../vdoc/languages"; import { - languageAtPosition, + languageFromBlock, mainLanguage, unadjustedRange, VirtualDoc, @@ -47,7 +46,6 @@ import { withVirtualDocUri, } from "../vdoc/vdoc"; - export function activateCodeFormatting(engine: MarkdownEngine) { return [new FormatCellCommand(engine)]; } @@ -59,41 +57,54 @@ export function embeddedDocumentFormattingProvider(engine: MarkdownEngine) { token: CancellationToken, next: ProvideDocumentFormattingEditsSignature ): Promise => { - if (isQuartoDoc(document, true)) { - // ensure we are dealing w/ the active document - const editor = window.activeTextEditor; - const activeDocument = editor?.document; - if ( - editor && - activeDocument?.uri.toString() === document.uri.toString() - ) { - const line = editor.selection.active.line; - const position = new Position(line, 0); - const tokens = engine.parse(document); - let language = languageAtPosition(tokens, position); - if (!language || !language.canFormat) { - language = mainLanguage(tokens, (lang) => !!lang.canFormat); - } - if (language) { - if (languageCanFormatDocument(language)) { - const vdoc = virtualDocForLanguage(document, tokens, language); - if (vdoc) { - return executeFormatDocumentProvider( - vdoc, - document, - formattingOptions(document.uri, vdoc.language, options) - ); - } - } else { - return (await formatActiveCell(editor, engine)) || []; - } - } - } - // ensure that other formatters don't ever run over qmd files + if (!isQuartoDoc(document, true)) { + // Delegate if we don't handle it + return next(document, options, token); + } + + // Ensure we are dealing w/ the active document + const activeEditor = window.activeTextEditor; + if (!activeEditor) { + // Ensure that other formatters don't ever run over qmd files + return []; + } + if (activeEditor.document.uri.toString() !== document.uri.toString()) { + return []; + } + + const tokens = engine.parse(document); + + // Figure out language to use. Try selection's block, then fall back to main doc language. + const includeFence = false; + const line = activeEditor.selection.active.line; + const block = languageBlockAtLine(tokens, line, includeFence); + + let language = block ? languageFromBlock(block) : undefined; + + if (!language || !language.canFormat) { + language = mainLanguage(tokens, (lang) => !!lang.canFormat); + } + + if (!language) { + // No language that can format in any way return []; + } + + if (languageCanFormatDocument(language)) { + // Full document formatting support + const vdoc = virtualDocForLanguage(document, tokens, language); + return executeFormatDocumentProvider( + vdoc, + document, + formattingOptions(document.uri, vdoc.language, options) + ); + } else if (block) { + // Just format the selected block if there is one + const edits = await formatBlock(document, block); + return edits ? edits : []; } else { - // delegate if we didn't handle it - return next(document, options, token); + // Nothing we can format + return []; } }; } @@ -108,22 +119,38 @@ export function embeddedDocumentRangeFormattingProvider( token: CancellationToken, next: ProvideDocumentRangeFormattingEditsSignature ): Promise => { - if (isQuartoDoc(document, true)) { - const tokens = engine.parse(document); - const beginBlock = languageBlockAtPosition(tokens, range.start, false); - const endBlock = languageBlockAtPosition(tokens, range.end, false); - if (beginBlock && (beginBlock.range.start.line === endBlock?.range.start.line)) { - const editor = window.activeTextEditor; - if (editor?.document?.uri.toString() === document.uri.toString()) { - return await formatActiveCell(editor, engine); - } - } - // ensure that other formatters don't ever run over qmd files - return []; - } else { - // if we don't perform any formatting, then call the next handler + if (!isQuartoDoc(document, true)) { + // If we don't perform any formatting, then call the next handler return next(document, range, options, token); } + + const includeFence = false; + const tokens = engine.parse(document); + + const block = languageBlockAtLine(tokens, range.start.line, includeFence); + if (!block) { + // Don't let anyone else format qmd files + return []; + } + + const endBlock = languageBlockAtLine(tokens, range.end.line, includeFence); + if (!endBlock) { + // Selection extends outside of a single block and into ambiguous non-block editor space + // (possibly spanning multiple blocks in the process) + return []; + } + + if (block.range.start.line !== endBlock.range.start.line) { + // Selection spans multiple blocks + return []; + } + + const edits = await formatBlock(document, block); + if (!edits) { + return []; + } + + return edits; }; } @@ -133,23 +160,41 @@ class FormatCellCommand implements Command { public async execute(): Promise { const editor = window.activeTextEditor; - const doc = editor?.document; - if (doc && isQuartoDoc(doc)) { - const edits = await formatActiveCell(editor, this.engine_); - if (edits) { - editor.edit((editBuilder) => { - // Sort edits by descending start position to avoid range shifting issues - edits - .slice() - .sort((a, b) => b.range.start.compareTo(a.range.start)) - .forEach((edit) => { - editBuilder.replace(edit.range, edit.newText); - }); - }); - } - } else { + if (!editor) { + // No active text editor + return; + } + + const document = editor.document; + if (!isQuartoDoc(document)) { window.showInformationMessage("Active editor is not a Quarto document"); + return; + } + + const includeFence = false; + + const tokens = this.engine_.parse(document); + const block = languageBlockAtLine(tokens, editor.selection.start.line, includeFence); + if (!block) { + window.showInformationMessage("Editor selection is not within a code cell."); + return; } + + const edits = await formatBlock(document, block); + if (!edits) { + // Nothing to do! Already formatted, or no formatter picked us up, or this language doesn't support formatting. + return; + } + + editor.edit((editBuilder) => { + // Sort edits by descending start position to avoid range shifting issues + edits + .slice() + .sort((a, b) => b.range.start.compareTo(a.range.start)) + .forEach((edit) => { + editBuilder.replace(edit.range, edit.newText); + }); + }); } } @@ -171,7 +216,6 @@ function formattingOptions( }; } - async function executeFormatDocumentProvider( vdoc: VirtualDoc, document: TextDocument, @@ -191,21 +235,18 @@ async function executeFormatDocumentProvider( } } -async function formatActiveCell(editor: TextEditor, engine: MarkdownEngine): Promise { - const doc = editor?.document; - const tokens = engine.parse(doc); - const line = editor.selection.start.line; - const position = new Position(line, 0); - const language = languageAtPosition(tokens, position); - const block = languageBlockAtPosition(tokens, position, false); - if (language?.canFormat && block) { - return formatBlock(doc, block, language); - } else { +async function formatBlock(doc: TextDocument, block: TokenMath | TokenCodeBlock): Promise { + // Extract language + const language = languageFromBlock(block); + if (!language) { + return undefined; + } + + // Refuse to format if not supported by this language + if (!language.canFormat) { return undefined; } -} -async function formatBlock(doc: TextDocument, block: TokenMath | TokenCodeBlock, language: EmbeddedLanguage): Promise { // Create virtual document containing the block const blockLines = lines(codeForExecutableLanguageBlock(block, false)); const vdoc = virtualDocForCode(blockLines, language); @@ -248,8 +289,7 @@ async function formatBlock(doc: TextDocument, block: TokenMath | TokenCodeBlock, } return adjustedEdits; -}; - +} function unadjustedEdits( edits: TextEdit[], diff --git a/packages/quarto-core/src/markdown/language.ts b/packages/quarto-core/src/markdown/language.ts index 201fc259..f14c5fda 100644 --- a/packages/quarto-core/src/markdown/language.ts +++ b/packages/quarto-core/src/markdown/language.ts @@ -1,5 +1,5 @@ /* - * lanugage.ts + * language.ts * * Copyright (C) 2022 by Posit Software, PBC * @@ -51,10 +51,9 @@ export function codeForExecutableLanguageBlock( } } - -export function languageBlockAtPosition( +export function languageBlockAtLine( tokens: Token[], - position: Position, + line: number, includeFence = false ) { for (const languageBlock of tokens.filter(isExecutableLanguageBlock)) { @@ -64,13 +63,20 @@ export function languageBlockAtPosition( start++; end--; } - if (position.line >= start && position.line <= end) { + if (line >= start && line <= end) { return languageBlock; } } return undefined; } +export function languageBlockAtPosition( + tokens: Token[], + position: Position, + includeFence = false +) { + return languageBlockAtLine(tokens, position.line, includeFence); +} export function isDisplayMath(token: Token): token is TokenMath { if (isMath(token)) { From f772e93009453284afad51bfef7605731f619fb5 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Fri, 13 Mar 2026 11:49:55 -0400 Subject: [PATCH 5/5] Add tests --- .../src/test/examples/format/basics.qmd | 20 +++ apps/vscode/src/test/format.test.ts | 128 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 apps/vscode/src/test/examples/format/basics.qmd create mode 100644 apps/vscode/src/test/format.test.ts diff --git a/apps/vscode/src/test/examples/format/basics.qmd b/apps/vscode/src/test/examples/format/basics.qmd new file mode 100644 index 00000000..fc7b7749 --- /dev/null +++ b/apps/vscode/src/test/examples/format/basics.qmd @@ -0,0 +1,20 @@ +--- +title: "Format Test" +format: html +--- + +## Markdown Section + +Some regular text here. + +```{python} +x = 1 + 1 +``` + +More markdown text. + +```{r} +y <- 1 + 1 +``` + +Final line. diff --git a/apps/vscode/src/test/format.test.ts b/apps/vscode/src/test/format.test.ts new file mode 100644 index 00000000..0fbda161 --- /dev/null +++ b/apps/vscode/src/test/format.test.ts @@ -0,0 +1,128 @@ +import * as vscode from "vscode"; +import * as assert from "assert"; +import { WORKSPACE_PATH, examplesOutUri, openAndShowExamplesOutTextDocument } from "./test-utils"; +import { languageBlockAtLine, languageNameFromBlock } from "quarto-core"; +import { MarkdownEngine } from "../markdown/engine"; + +suite("Format Cell", function () { + const engine = new MarkdownEngine(); + + suiteSetup(async function () { + await vscode.workspace.fs.delete(examplesOutUri(), { recursive: true }); + await vscode.workspace.fs.copy(vscode.Uri.file(WORKSPACE_PATH), examplesOutUri()); + }); + + suite("languageBlockAtLine", function () { + test("Returns undefined for YAML front matter lines", async function () { + const { doc } = await openAndShowExamplesOutTextDocument("format/basics.qmd"); + const tokens = engine.parse(doc); + + // Lines 0-3 are YAML front matter (---, title, format, ---) + assert.strictEqual(languageBlockAtLine(tokens, 0), undefined); + assert.strictEqual(languageBlockAtLine(tokens, 1), undefined); + assert.strictEqual(languageBlockAtLine(tokens, 3), undefined); + }); + + test("Returns undefined for markdown lines", async function () { + const { doc } = await openAndShowExamplesOutTextDocument("format/basics.qmd"); + const tokens = engine.parse(doc); + + // Line 5 is "## Markdown Section", line 7 is "Some regular text here." + assert.strictEqual(languageBlockAtLine(tokens, 5), undefined); + assert.strictEqual(languageBlockAtLine(tokens, 7), undefined); + }); + + test("Returns undefined for fence lines (includeFence=false)", async function () { + const { doc } = await openAndShowExamplesOutTextDocument("format/basics.qmd"); + const tokens = engine.parse(doc); + + // Line 9 (0-indexed) is the opening fence ```{python} + assert.strictEqual(languageBlockAtLine(tokens, 9, false), undefined); + // Line 11 is the closing fence ``` + assert.strictEqual(languageBlockAtLine(tokens, 11, false), undefined); + }); + + test("Returns the block for fence lines when includeFence=true", async function () { + const { doc } = await openAndShowExamplesOutTextDocument("format/basics.qmd"); + const tokens = engine.parse(doc); + + // Line 9 (0-indexed) is the opening fence ```{python} + const block = languageBlockAtLine(tokens, 9, true); + assert.ok(block); + assert.strictEqual(languageNameFromBlock(block), "python"); + }); + + test("Distinguishes between different code cells", async function () { + const { doc } = await openAndShowExamplesOutTextDocument("format/basics.qmd"); + const tokens = engine.parse(doc); + + // Line 10 (0-indexed) is `x = 1 + 1` in the python block + const pythonBlock = languageBlockAtLine(tokens, 10); + assert.ok(pythonBlock); + assert.strictEqual(languageNameFromBlock(pythonBlock), "python"); + + // Line 16 (0-indexed) is `y <- 1 + 1` in the R block + const rBlock = languageBlockAtLine(tokens, 16); + assert.ok(rBlock); + assert.strictEqual(languageNameFromBlock(rBlock), "r"); + }); + }); + + // Hard to test actual formatting behavior without a formatter + suite("quarto.formatCell command", function () { + test("Does not modify document when cursor is on a markdown line", async function () { + const { doc, editor } = await openAndShowExamplesOutTextDocument("format/basics.qmd"); + const before = doc.getText(); + + // Move cursor to a markdown line (line 7, 0-indexed: "Some regular text here.") + editor.selection = new vscode.Selection(7, 0, 7, 0); + await vscode.commands.executeCommand("quarto.formatCell"); + + assert.strictEqual(doc.getText(), before); + }); + + test("Shows info message when cursor is on a markdown line", async function () { + const { editor } = await openAndShowExamplesOutTextDocument("format/basics.qmd"); + + // Mock `showInformationMessage` + const original = vscode.window.showInformationMessage; + const messages: string[] = []; + vscode.window.showInformationMessage = async (msg: string) => { + messages.push(msg); + return undefined as any; + }; + + try { + editor.selection = new vscode.Selection(7, 0, 7, 0); + await vscode.commands.executeCommand("quarto.formatCell"); + + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0], "Editor selection is not within a code cell."); + } finally { + vscode.window.showInformationMessage = original; + } + }); + + test("Does not show info message when cursor is inside a code cell", async function () { + const { editor } = await openAndShowExamplesOutTextDocument("format/basics.qmd"); + + // Mock `showInformationMessage` + const original = vscode.window.showInformationMessage; + const messages: string[] = []; + vscode.window.showInformationMessage = async (msg: string) => { + messages.push(msg); + return undefined as any; + }; + + try { + // Line 10: `x = 1 + 1` inside the python block + editor.selection = new vscode.Selection(10, 0, 10, 0); + await vscode.commands.executeCommand("quarto.formatCell"); + + assert.strictEqual(messages.length, 0); + } finally { + vscode.window.showInformationMessage = original; + } + }); + }); +});