From ab114d331d72bbe10770dfec5aa683de954ba2ea Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 12 Aug 2026 15:56:34 -0400 Subject: [PATCH 1/3] Add reflow command --- apps/vscode/package.json | 17 ++ apps/vscode/src/main.ts | 3 + apps/vscode/src/providers/cell/options.ts | 4 +- apps/vscode/src/providers/cell/reflow.ts | 273 ++++++++++++++++++++++ apps/vscode/src/test/examples/reflow.qmd | 19 ++ apps/vscode/src/test/reflow.test.ts | 206 ++++++++++++++++ 6 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 apps/vscode/src/providers/cell/reflow.ts create mode 100644 apps/vscode/src/test/examples/reflow.qmd create mode 100644 apps/vscode/src/test/reflow.test.ts diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 71f587ac..a83e8c8a 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -377,6 +377,11 @@ "title": "Format Cell", "category": "Quarto" }, + { + "command": "quarto.reflowCommentInCell", + "title": "Reflow Comment in Cell", + "category": "Quarto" + }, { "command": "quarto.previewMath", "category": "Quarto", @@ -665,6 +670,11 @@ "when": "editorLangId == quarto && !editorHasSelection", "group": "1_modification" }, + { + "command": "quarto.reflowCommentInCell", + "when": "editorLangId == quarto && !editorHasSelection", + "group": "1_modification" + }, { "command": "quarto.editInVisualMode", "when": "resourceScheme != untitled && editorLangId == quarto || resourceScheme != untitled && editorLangId == markdown", @@ -1022,6 +1032,13 @@ "default": 500, "markdownDescription": "Delay in milliseconds before updating diagnostics after document changes." }, + "quarto.cells.reflowColumn": { + "order": 28, + "scope": "window", + "type": "number", + "default": 80, + "markdownDescription": "Maximum line length used by the **Quarto: Reflow Comment in Cell** command." + }, "quarto.cells.background.enabled": { "type": "boolean", "description": "Enable coloring the background of executable code cells.", diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 658532e1..2681ecb9 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -21,6 +21,7 @@ import { kQuartoDocSelector } from "./core/doc"; import { activateLsp, deactivate as deactivateLsp } from "./lsp/client"; import { activateEmbeddedDiagnostics, type EmbeddedDiagnosticsService } from "./providers/diagnostics"; import { cellCommands } from "./providers/cell/commands"; +import { reflowCommands } from "./providers/cell/reflow"; import { quartoCellExecuteCodeLensProvider } from "./providers/cell/codelens"; import { activateQuartoAssistPanel } from "./providers/assist/panel"; import { activatePreview } from "./providers/preview/preview"; @@ -177,6 +178,8 @@ export async function activate(context: vscode.ExtensionContext): Promise = { apl: "⍝", }; -function escapeRegExp(str: string) { +export function escapeRegExp(str: string) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string } diff --git a/apps/vscode/src/providers/cell/reflow.ts b/apps/vscode/src/providers/cell/reflow.ts new file mode 100644 index 00000000..03f100c0 --- /dev/null +++ b/apps/vscode/src/providers/cell/reflow.ts @@ -0,0 +1,273 @@ +/* + * reflow.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import { EndOfLine, Position, Range, window, workspace } from "vscode"; + +import { lines } from "core"; +import { + TokenCodeBlock, + TokenMath, + codeForExecutableLanguageBlock, + languageBlockAtLine, + languageNameFromBlock, +} from "quarto-core"; + +import { Command } from "../../core/command"; +import { isQuartoDoc } from "../../core/doc"; +import { MarkdownEngine } from "../../markdown/engine"; +import { languageFromBlock } from "../../vdoc/vdoc"; +import { escapeRegExp, langCommentChars, optionCommentPattern } from "./options"; + +export function reflowCommands(engine: MarkdownEngine): Command[] { + return [new ReflowCommentInCellCommand(engine)]; +} + +const kDefaultReflowColumn = 80; + +class ReflowCommentInCellCommand implements Command { + public readonly id = "quarto.reflowCommentInCell"; + constructor(private readonly engine_: MarkdownEngine) { } + + public async execute(): Promise { + const editor = window.activeTextEditor; + 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 comment = lineCommentForBlock(block); + if (!comment) { + window.showInformationMessage( + `Comment reflow is not supported for ${languageNameFromBlock(block)} cells.` + ); + return; + } + + const column = workspace + .getConfiguration("quarto") + .get("cells.reflowColumn", kDefaultReflowColumn); + + const cellLines = lines(codeForExecutableLanguageBlock(block, false)); + const reflows = reflowComments(cellLines, comment, column); + if (reflows.length === 0) { + return; + } + + // Use the document's line ending to avoid introducing mixed EOL in CRLF files. + const eol = document.eol === EndOfLine.CRLF ? "\r\n" : "\n"; + + // The `+ 1` skips the opening fence line. + const lineOffset = block.range.start.line + 1; + + await editor.edit((editBuilder) => { + // Sort by descending start position to avoid range shifting issues + [...reflows] + .sort((a, b) => b.startLine - a.startLine) + .forEach((reflow) => { + const range = new Range( + new Position(lineOffset + reflow.startLine, 0), + document.lineAt(lineOffset + reflow.endLine).range.end + ); + editBuilder.replace(range, reflow.newLines.join(eol)); + }); + }); + } +} + +// Resolve the line comment string for a block. Prefer the canonical comment +// from `editor-core` (via the embedded language) and fall back to the +// executor-oriented map in `cell/options.ts` for languages that aren't +// embedded (lua, haskell, fortran, ...). Languages that only have block +// comments (a [open, close] tuple in the map) return undefined. +function lineCommentForBlock(block: TokenMath | TokenCodeBlock): string | undefined { + const language = languageFromBlock(block); + if (language?.comment) { + return language.comment; + } + const commentChars = langCommentChars(languageNameFromBlock(block)); + return commentChars.length === 1 ? commentChars[0] : undefined; +} + +export interface CommentReflow { + /** First line of the replaced region (0-based, relative to the cell body). */ + startLine: number; + /** Last line of the replaced region (inclusive). */ + endLine: number; + /** Replacement lines (may be fewer or more than the region spans). */ + newLines: string[]; +} + +interface ParsedCommentLine { + raw: string; + indent: string; + prefix: string; + content: string; + kind: "blank" | "fixed" | "text"; +} + +/** + * Reflow the full-line comments in a cell body to the given column. + * + * Consecutive comment lines form paragraphs whose words are re-wrapped + * greedily. Paragraphs are delimited by code lines, empty comment lines + * (which are preserved as separators), changes in indentation or comment + * prefix, and "fixed" lines that are kept verbatim: divider/banner lines + * without any word content (`# ------`, `#######`) and section headers + * ending in a run of `-`/`=` (`# Load data ----`). Quarto option directives + * (`#| echo: false`) and lines that mix code and a trailing comment are + * never touched. + * + * Returns one replacement per contiguous comment run that actually changed. + */ +export function reflowComments( + cellLines: string[], + comment: string, + column: number +): CommentReflow[] { + const optionPattern = optionCommentPattern(comment); + // An extended comment prefix: one or more repetitions of the comment + // string, optionally followed by a doc-comment marker. This keeps prefixes + // like `#'` (roxygen), `///` and `//!` (doc comments) intact when wrapping. + const prefixPattern = new RegExp("^((?:" + escapeRegExp(comment) + ")+[!'/]?)"); + + const parseLine = (raw: string): ParsedCommentLine | undefined => { + const trimmed = raw.trimStart(); + if (!trimmed.startsWith(comment)) { + return undefined; + } + // Never touch cell option directives (`#| echo: false`) + if (optionPattern.test(trimmed)) { + return undefined; + } + const indent = raw.slice(0, raw.length - trimmed.length); + let prefix = prefixPattern.exec(trimmed)![1]; + let rest = trimmed.slice(prefix.length); + if (rest !== "" && !/^[ \t]/.test(rest)) { + // The extended prefix runs straight into other text (e.g. `#--- foo`): + // fall back to the bare comment string as the prefix. + prefix = comment; + rest = trimmed.slice(comment.length); + } + const content = rest.trim(); + const kind = + content === "" + ? prefix === comment + ? "blank" + : "fixed" // banner lines like `#####` are kept verbatim + : !/[\p{L}\p{N}]/u.test(content) || /[-=]{4,}$/.test(content) + ? "fixed" // dividers (`# ----`) and section headers (`# Load ----`) + : "text"; + return { raw, indent, prefix, content, kind }; + }; + + const reflows: CommentReflow[] = []; + let i = 0; + while (i < cellLines.length) { + if (!parseLine(cellLines[i])) { + i++; + continue; + } + // Collect a contiguous run of comment lines + const runStart = i; + const run: ParsedCommentLine[] = []; + for (; i < cellLines.length; i++) { + const parsed = parseLine(cellLines[i]); + if (!parsed) { + break; + } + run.push(parsed); + } + const runEnd = i - 1; + const newLines = reflowRun(run, column); + const original = cellLines.slice(runStart, runEnd + 1); + if ( + newLines.length !== original.length || + newLines.some((line, idx) => line !== original[idx]) + ) { + reflows.push({ startLine: runStart, endLine: runEnd, newLines }); + } + } + return reflows; +} + +function reflowRun(run: ParsedCommentLine[], column: number): string[] { + const out: string[] = []; + let paragraph: ParsedCommentLine[] = []; + const flush = () => { + if (paragraph.length > 0) { + out.push(...wrapParagraph(paragraph, column)); + paragraph = []; + } + }; + for (const line of run) { + if (line.kind === "blank") { + flush(); + // Normalize empty comment lines (drops trailing whitespace) + out.push(line.indent + line.prefix); + } else if (line.kind === "fixed") { + flush(); + out.push(line.raw); + } else { + if ( + paragraph.length > 0 && + (paragraph[0].indent !== line.indent || paragraph[0].prefix !== line.prefix) + ) { + flush(); + } + paragraph.push(line); + } + } + flush(); + return out; +} + +function wrapParagraph(paragraph: ParsedCommentLine[], column: number): string[] { + const linePrefix = paragraph[0].indent + paragraph[0].prefix + " "; + const width = Math.max(1, column - linePrefix.length); + const words = paragraph + .flatMap((line) => line.content.split(/\s+/)) + .filter((word) => word.length > 0); + const out: string[] = []; + let current = ""; + for (const word of words) { + if (current === "") { + current = word; + } else if (current.length + 1 + word.length <= width) { + current += " " + word; + } else { + out.push(linePrefix + current); + current = word; + } + } + if (current !== "") { + out.push(linePrefix + current); + } + return out; +} diff --git a/apps/vscode/src/test/examples/reflow.qmd b/apps/vscode/src/test/examples/reflow.qmd new file mode 100644 index 00000000..8ef9bd80 --- /dev/null +++ b/apps/vscode/src/test/examples/reflow.qmd @@ -0,0 +1,19 @@ +--- +title: Reflow +--- + +## Comments + +```{r} +# It is a truth universally acknowledged, that a single man in possession of a good fortune must be in want of a wife. +# +# "My dear Mr. Bennet," said his lady to him one day, "have you heard that Netherfield Park is let at last?" + +1 + 1 +``` + +```{python} +#| echo: false +# However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families. +x = 1 +``` diff --git a/apps/vscode/src/test/reflow.test.ts b/apps/vscode/src/test/reflow.test.ts new file mode 100644 index 00000000..3cd80c25 --- /dev/null +++ b/apps/vscode/src/test/reflow.test.ts @@ -0,0 +1,206 @@ +import * as vscode from "vscode"; +import * as assert from "assert"; +import { WORKSPACE_PATH, examplesOutUri, openAndShowExamplesOutTextDocument } from "./test-utils"; +import { reflowComments } from "../providers/cell/reflow"; + +suite("Reflow Comment in Cell", function () { + + suite("reflowComments", function () { + test("Wraps a long comment to the column", function () { + const reflows = reflowComments( + ["# aaa bbb ccc ddd", "x <- 1"], + "#", + 10 + ); + assert.strictEqual(reflows.length, 1); + assert.deepStrictEqual(reflows[0], { + startLine: 0, + endLine: 0, + newLines: ["# aaa bbb", "# ccc ddd"], + }); + }); + + test("Joins short comment lines up to the column", function () { + const reflows = reflowComments(["# aaa", "# bbb", "# ccc"], "#", 80); + assert.strictEqual(reflows.length, 1); + assert.deepStrictEqual(reflows[0].newLines, ["# aaa bbb ccc"]); + assert.strictEqual(reflows[0].endLine, 2); + }); + + test("Returns no edits when comments already fit", function () { + const reflows = reflowComments(["# aaa bbb", "x <- 1"], "#", 80); + assert.deepStrictEqual(reflows, []); + }); + + test("Never touches cell option directives", function () { + const reflows = reflowComments( + ["#| echo: false", "#| label: a-very-long-label", "# aaa bbb ccc", "x <- 1"], + "#", + 10 + ); + assert.strictEqual(reflows.length, 1); + assert.strictEqual(reflows[0].startLine, 2); + assert.strictEqual(reflows[0].endLine, 2); + }); + + test("Blank comment lines separate paragraphs", function () { + const reflows = reflowComments(["# aaa bbb ccc", "#", "# ddd"], "#", 10); + assert.strictEqual(reflows.length, 1); + assert.deepStrictEqual(reflows[0].newLines, [ + "# aaa bbb", + "# ccc", + "#", + "# ddd", + ]); + // ...and trailing whitespace on blank comment lines is normalized + const normalized = reflowComments(["# aaa", "# ", "# bbb"], "#", 80); + assert.deepStrictEqual(normalized[0].newLines, ["# aaa", "#", "# bbb"]); + }); + + test("Code lines delimit comment runs and are untouched", function () { + const reflows = reflowComments( + ["# aaa bbb ccc", "x <- 1 # trailing comment stays put", "# ddd eee fff"], + "#", + 10 + ); + assert.strictEqual(reflows.length, 2); + assert.deepStrictEqual(reflows[0], { + startLine: 0, + endLine: 0, + newLines: ["# aaa bbb", "# ccc"], + }); + assert.deepStrictEqual(reflows[1], { + startLine: 2, + endLine: 2, + newLines: ["# ddd eee", "# fff"], + }); + }); + + test("Keeps dividers, banners, and section headers verbatim", function () { + const reflows = reflowComments( + [ + "# ---------------------------------------", + "# aaa bbb ccc", + "###########################################", + "# Load the data ----", + ], + "#", + 10 + ); + assert.strictEqual(reflows.length, 1); + assert.deepStrictEqual(reflows[0].newLines, [ + "# ---------------------------------------", + "# aaa bbb", + "# ccc", + "###########################################", + "# Load the data ----", + ]); + }); + + test("Preserves indentation and extended prefixes", function () { + const reflows = reflowComments( + [" # aaa bbb ccc", "#' roxygen docs stay grouped apart", "#' from plain comments"], + "#", + 12 + ); + assert.strictEqual(reflows.length, 1); + assert.deepStrictEqual(reflows[0].newLines, [ + " # aaa bbb", + " # ccc", + "#' roxygen", + "#' docs stay", + "#' grouped", + "#' apart", + "#' from", + "#' plain", + "#' comments", + ]); + }); + + test("Supports multi-character comment strings", function () { + const reflows = reflowComments(["-- aaa bbb ccc", "select 1"], "--", 12); + assert.strictEqual(reflows.length, 1); + assert.deepStrictEqual(reflows[0].newLines, ["-- aaa bbb", "-- ccc"]); + }); + }); + + suite("quarto.reflowCommentInCell command", function () { + suiteSetup(async function () { + await vscode.workspace.fs.delete(examplesOutUri(), { recursive: true }); + await vscode.workspace.fs.copy(vscode.Uri.file(WORKSPACE_PATH), examplesOutUri()); + }); + + teardown(async function () { + // Revert any document mutation so tests stay independent + await vscode.commands.executeCommand("undo"); + }); + + test("Reflows the comments in the R cell at the cursor", async function () { + const { doc, editor } = await openAndShowExamplesOutTextDocument("reflow.qmd"); + + // Line 7: the long comment in the R cell + editor.selection = new vscode.Selection(7, 0, 7, 0); + await vscode.commands.executeCommand("quarto.reflowCommentInCell"); + + const cell = doc.getText().split("\n").slice(6, 14).join("\n"); + assert.strictEqual( + cell, + [ + "```{r}", + "# It is a truth universally acknowledged, that a single man in possession of a", + "# good fortune must be in want of a wife.", + "#", + '# "My dear Mr. Bennet," said his lady to him one day, "have you heard that', + "# Netherfield Park is let at last?\"", + "", + "1 + 1", + ].join("\n") + ); + }); + + test("Leaves option directives and code untouched in the Python cell", async function () { + const { doc, editor } = await openAndShowExamplesOutTextDocument("reflow.qmd"); + + // Line 16: the long comment in the python cell + editor.selection = new vscode.Selection(16, 0, 16, 0); + await vscode.commands.executeCommand("quarto.reflowCommentInCell"); + + const lines = doc.getText().split("\n"); + assert.strictEqual(lines[15], "#| echo: false"); + assert.strictEqual( + lines[16], + "# However little known the feelings or views of such a man may be on his first" + ); + assert.strictEqual( + lines[17], + "# entering a neighbourhood, this truth is so well fixed in the minds of the" + ); + assert.strictEqual(lines[18], "# surrounding families."); + assert.strictEqual(lines[19], "x = 1"); + }); + + test("Shows info message when cursor is on a markdown line", async function () { + const { doc, editor } = await openAndShowExamplesOutTextDocument("reflow.qmd"); + const before = doc.getText(); + + const original = vscode.window.showInformationMessage; + const messages: string[] = []; + vscode.window.showInformationMessage = async (msg: string) => { + messages.push(msg); + return undefined as any; + }; + + try { + // Line 4: "## Comments" + editor.selection = new vscode.Selection(4, 0, 4, 0); + await vscode.commands.executeCommand("quarto.reflowCommentInCell"); + + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0], "Editor selection is not within a code cell."); + assert.strictEqual(doc.getText(), before); + } finally { + vscode.window.showInformationMessage = original; + } + }); + }); +}); From 245f3ef4f3947973efaf6f8319449680cd7c6873 Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 12 Aug 2026 15:59:42 -0400 Subject: [PATCH 2/3] Add CHANGELOG --- apps/vscode/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 444cf4e7..c9aed331 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -4,6 +4,7 @@ - Reduce memory usage by only starting the language server (LSP) in projects containing Quarto documents (https://github.com/quarto-dev/quarto/pull/1059). - Fixed a bug where single-line display math with a cross-reference label (e.g. `$$1+1$$ {#eq-spec0}`), or an unclosed `$$`, stopped the rest of the document from being parsed, so headings went missing from the outline, LaTeX preview was unavailable, and code cells below could not be run (). +- Adds a command "Quarto: Reflow Comment in Cell" that breaks up long comment lines into multiple comment lines (by default 80 characters is considerered long, but that is configurable by `quarto.cells.reflowColumn`) (). ## 1.135.0 (Release on 2026-07-08) From 63e239a3c1c654129b2f8f2d3ca8b87fe588a674 Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 12 Aug 2026 16:05:19 -0400 Subject: [PATCH 3/3] fix command name --- apps/vscode/CHANGELOG.md | 2 +- apps/vscode/package.json | 4 ++-- apps/vscode/src/test/reflow.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index c9aed331..f9a60cea 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -4,7 +4,7 @@ - Reduce memory usage by only starting the language server (LSP) in projects containing Quarto documents (https://github.com/quarto-dev/quarto/pull/1059). - Fixed a bug where single-line display math with a cross-reference label (e.g. `$$1+1$$ {#eq-spec0}`), or an unclosed `$$`, stopped the rest of the document from being parsed, so headings went missing from the outline, LaTeX preview was unavailable, and code cells below could not be run (). -- Adds a command "Quarto: Reflow Comment in Cell" that breaks up long comment lines into multiple comment lines (by default 80 characters is considerered long, but that is configurable by `quarto.cells.reflowColumn`) (). +- Adds a command "Quarto: Reflow Comments in Cell" that breaks up long comment lines into multiple comment lines (by default 80 characters is considered long, but that is configurable by `quarto.cells.reflowColumn`) (). ## 1.135.0 (Release on 2026-07-08) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index a83e8c8a..e8a8be5c 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -379,7 +379,7 @@ }, { "command": "quarto.reflowCommentInCell", - "title": "Reflow Comment in Cell", + "title": "Reflow Comments in Cell", "category": "Quarto" }, { @@ -1037,7 +1037,7 @@ "scope": "window", "type": "number", "default": 80, - "markdownDescription": "Maximum line length used by the **Quarto: Reflow Comment in Cell** command." + "markdownDescription": "Maximum line length used by the **Quarto: Reflow Comments in Cell** command." }, "quarto.cells.background.enabled": { "type": "boolean", diff --git a/apps/vscode/src/test/reflow.test.ts b/apps/vscode/src/test/reflow.test.ts index 3cd80c25..a9992c01 100644 --- a/apps/vscode/src/test/reflow.test.ts +++ b/apps/vscode/src/test/reflow.test.ts @@ -3,7 +3,7 @@ import * as assert from "assert"; import { WORKSPACE_PATH, examplesOutUri, openAndShowExamplesOutTextDocument } from "./test-utils"; import { reflowComments } from "../providers/cell/reflow"; -suite("Reflow Comment in Cell", function () { +suite("Reflow Comments in Cell", function () { suite("reflowComments", function () { test("Wraps a long comment to the column", function () {