From ab114d331d72bbe10770dfec5aa683de954ba2ea Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 12 Aug 2026 15:56:34 -0400 Subject: [PATCH 1/6] 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/6] 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/6] 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 () { From 84de722caad009a07501a2659a0d3343664bb9f2 Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 19 Aug 2026 12:50:09 -0400 Subject: [PATCH 4/6] Only split long comment lines, dont join short ones --- .../src/providers/cell/comment-chars.ts | 2 +- apps/vscode/src/providers/cell/reflow.ts | 147 ++++++------------ apps/vscode/src/test/reflow.test.ts | 103 ++++++------ 3 files changed, 104 insertions(+), 148 deletions(-) diff --git a/apps/vscode/src/providers/cell/comment-chars.ts b/apps/vscode/src/providers/cell/comment-chars.ts index 44bc0c99..56a5f7ed 100644 --- a/apps/vscode/src/providers/cell/comment-chars.ts +++ b/apps/vscode/src/providers/cell/comment-chars.ts @@ -80,6 +80,6 @@ export function optionCommentPattern(comment: string) { return new RegExp("^" + escapeRegExp(comment) + "\\s*\\| ?"); } -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 index 03f100c0..8c45e100 100644 --- a/apps/vscode/src/providers/cell/reflow.ts +++ b/apps/vscode/src/providers/cell/reflow.ts @@ -28,7 +28,7 @@ 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"; +import { escapeRegExp, langCommentChars, optionCommentPattern } from "./comment-chars"; export function reflowCommands(engine: MarkdownEngine): Command[] { return [new ReflowCommentInCellCommand(engine)]; @@ -87,13 +87,13 @@ class ReflowCommentInCellCommand implements Command { const lineOffset = block.range.start.line + 1; await editor.edit((editBuilder) => { - // Sort by descending start position to avoid range shifting issues + // Sort by descending position to avoid range shifting issues [...reflows] - .sort((a, b) => b.startLine - a.startLine) + .sort((a, b) => b.line - a.line) .forEach((reflow) => { const range = new Range( - new Position(lineOffset + reflow.startLine, 0), - document.lineAt(lineOffset + reflow.endLine).range.end + new Position(lineOffset + reflow.line, 0), + document.lineAt(lineOffset + reflow.line).range.end ); editBuilder.replace(range, reflow.newLines.join(eol)); }); @@ -116,35 +116,32 @@ function lineCommentForBlock(block: TokenMath | TokenCodeBlock): string | undefi } 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). */ + /** The replaced line (0-based, relative to the cell body). */ + line: number; + /** Replacement lines. */ newLines: string[]; } -interface ParsedCommentLine { - raw: string; +interface WrappableComment { indent: string; prefix: string; content: string; - kind: "blank" | "fixed" | "text"; } /** - * Reflow the full-line comments in a cell body to the given column. + * Split the full-line comments in a cell body that extend past the given + * column. Each long comment line is wrapped greedily onto continuation lines + * that repeat its indentation and comment prefix. Lines are only ever split, + * never joined, and lines that aren't split are never rewritten. * - * 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. + * Only comments of the form ` ` are wrapped: lines where the + * prefix runs straight into other characters (`#!/usr/bin/env bash`, + * `#--- foo`), Quarto option directives (`#| echo: false`), empty comment + * lines, divider/banner lines without any word content (`# ------`, + * `#######`), section headers ending in a run of `-`/`=` (`# Load data ----`), + * and lines that mix code and a trailing comment are all left verbatim. * - * Returns one replacement per contiguous comment run that actually changed. + * Returns one replacement per line that was split. */ export function reflowComments( cellLines: string[], @@ -157,7 +154,7 @@ export function reflowComments( // like `#'` (roxygen), `///` and `//!` (doc comments) intact when wrapping. const prefixPattern = new RegExp("^((?:" + escapeRegExp(comment) + ")+[!'/]?)"); - const parseLine = (raw: string): ParsedCommentLine | undefined => { + const parseLine = (raw: string): WrappableComment | undefined => { const trimmed = raw.trimStart(); if (!trimmed.startsWith(comment)) { return undefined; @@ -167,93 +164,47 @@ export function reflowComments( return undefined; } const indent = raw.slice(0, raw.length - trimmed.length); - let prefix = prefixPattern.exec(trimmed)![1]; - let rest = trimmed.slice(prefix.length); + const prefix = prefixPattern.exec(trimmed)![1]; + const 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); + // The prefix runs straight into other characters (`#!/usr/bin/env`, + // `#--- foo`): leave the line verbatim. + return undefined; } 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 }; + if (content === "") { + return undefined; + } + if (!/[\p{L}\p{N}]/u.test(content) || /[-=]{4,}$/.test(content)) { + // Dividers (`# ----`) and section headers (`# Load ----`) + return undefined; + } + return { indent, prefix, content }; }; const reflows: CommentReflow[] = []; - let i = 0; - while (i < cellLines.length) { - if (!parseLine(cellLines[i])) { - i++; - continue; + cellLines.forEach((raw, line) => { + if (raw.length <= column) { + return; } - // 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 parsed = parseLine(raw); + if (!parsed) { + return; } - 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 }); + const newLines = wrapComment(parsed, column); + // A single wrapped line means nothing was split (e.g. one unbreakable + // word, or only trailing whitespace past the column): leave it verbatim. + if (newLines.length > 1) { + reflows.push({ line, 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 + " "; +function wrapComment(comment: WrappableComment, column: number): string[] { + const linePrefix = comment.indent + comment.prefix + " "; const width = Math.max(1, column - linePrefix.length); - const words = paragraph - .flatMap((line) => line.content.split(/\s+/)) - .filter((word) => word.length > 0); + const words = comment.content.split(/\s+/); const out: string[] = []; let current = ""; for (const word of words) { diff --git a/apps/vscode/src/test/reflow.test.ts b/apps/vscode/src/test/reflow.test.ts index a9992c01..552f08fa 100644 --- a/apps/vscode/src/test/reflow.test.ts +++ b/apps/vscode/src/test/reflow.test.ts @@ -6,7 +6,7 @@ import { reflowComments } from "../providers/cell/reflow"; suite("Reflow Comments in Cell", function () { suite("reflowComments", function () { - test("Wraps a long comment to the column", function () { + test("Splits a long comment at the column", function () { const reflows = reflowComments( ["# aaa bbb ccc ddd", "x <- 1"], "#", @@ -14,17 +14,14 @@ suite("Reflow Comments in Cell", function () { ); assert.strictEqual(reflows.length, 1); assert.deepStrictEqual(reflows[0], { - startLine: 0, - endLine: 0, + line: 0, newLines: ["# aaa bbb", "# ccc ddd"], }); }); - test("Joins short comment lines up to the column", function () { + test("Never joins short comment lines", 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); + assert.deepStrictEqual(reflows, []); }); test("Returns no edits when comments already fit", function () { @@ -32,46 +29,53 @@ suite("Reflow Comments in Cell", function () { assert.deepStrictEqual(reflows, []); }); - test("Never touches cell option directives", function () { + test("Never rewrites lines it does not split", function () { + // Irregular spacing, trailing whitespace, and blank comment lines are + // all left alone when the line fits within the column + assert.deepStrictEqual( + reflowComments(["# aaa bbb", "# ", "# ccc "], "#", 80), + [] + ); + // A line over the column is left alone when splitting is impossible: + // one unbreakable word, or only trailing whitespace past the column + assert.deepStrictEqual( + reflowComments(["# aaaaaaaaaaaaaa", "# aaa "], "#", 10), + [] + ); + }); + + test("Leaves shebangs and other unspaced prefixes verbatim", function () { const reflows = reflowComments( - ["#| echo: false", "#| label: a-very-long-label", "# aaa bbb ccc", "x <- 1"], + ["#!/usr/bin/env bash -o pipefail", "#--- not a wrappable comment"], "#", 10 ); - assert.strictEqual(reflows.length, 1); - assert.strictEqual(reflows[0].startLine, 2); - assert.strictEqual(reflows[0].endLine, 2); + assert.deepStrictEqual(reflows, []); }); - 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", + test("Never touches cell option directives", function () { + const reflows = reflowComments( + ["#| echo: false", "#| label: a-very-long-label", "# aaa bbb ccc", "x <- 1"], "#", - "# ddd", - ]); - // ...and trailing whitespace on blank comment lines is normalized - const normalized = reflowComments(["# aaa", "# ", "# bbb"], "#", 80); - assert.deepStrictEqual(normalized[0].newLines, ["# aaa", "#", "# bbb"]); + 10 + ); + assert.strictEqual(reflows.length, 1); + assert.strictEqual(reflows[0].line, 2); }); - test("Code lines delimit comment runs and are untouched", function () { + test("Splits each long line separately, leaving others untouched", function () { const reflows = reflowComments( - ["# aaa bbb ccc", "x <- 1 # trailing comment stays put", "# ddd eee fff"], + ["# 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, + line: 0, newLines: ["# aaa bbb", "# ccc"], }); assert.deepStrictEqual(reflows[1], { - startLine: 2, - endLine: 2, + line: 3, newLines: ["# ddd eee", "# fff"], }); }); @@ -88,33 +92,34 @@ suite("Reflow Comments in Cell", function () { 10 ); assert.strictEqual(reflows.length, 1); - assert.deepStrictEqual(reflows[0].newLines, [ - "# ---------------------------------------", - "# aaa bbb", - "# ccc", - "###########################################", - "# Load the data ----", - ]); + assert.deepStrictEqual(reflows[0], { + line: 1, + newLines: ["# aaa bbb", "# ccc"], + }); }); test("Preserves indentation and extended prefixes", function () { const reflows = reflowComments( - [" # aaa bbb ccc", "#' roxygen docs stay grouped apart", "#' from plain comments"], + [" # aaa bbb ccc", "#' roxygen docs stay wrapped with their prefix"], "#", 12 ); - assert.strictEqual(reflows.length, 1); - assert.deepStrictEqual(reflows[0].newLines, [ - " # aaa bbb", - " # ccc", - "#' roxygen", - "#' docs stay", - "#' grouped", - "#' apart", - "#' from", - "#' plain", - "#' comments", - ]); + assert.strictEqual(reflows.length, 2); + assert.deepStrictEqual(reflows[0], { + line: 0, + newLines: [" # aaa bbb", " # ccc"], + }); + assert.deepStrictEqual(reflows[1], { + line: 1, + newLines: [ + "#' roxygen", + "#' docs stay", + "#' wrapped", + "#' with", + "#' their", + "#' prefix", + ], + }); }); test("Supports multi-character comment strings", function () { From b47ee38f16311eb17a29c7936e60407a6d682fdd Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 19 Aug 2026 12:50:38 -0400 Subject: [PATCH 5/6] When the user has a selection, only reflow selected lines --- apps/vscode/src/providers/cell/reflow.ts | 25 ++++++++++++--- apps/vscode/src/test/reflow.test.ts | 39 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/vscode/src/providers/cell/reflow.ts b/apps/vscode/src/providers/cell/reflow.ts index 8c45e100..eb5d5684 100644 --- a/apps/vscode/src/providers/cell/reflow.ts +++ b/apps/vscode/src/providers/cell/reflow.ts @@ -74,8 +74,28 @@ class ReflowCommentInCellCommand implements Command { .getConfiguration("quarto") .get("cells.reflowColumn", kDefaultReflowColumn); + // The `+ 1` skips the opening fence line. + const lineOffset = block.range.start.line + 1; + const cellLines = lines(codeForExecutableLanguageBlock(block, false)); - const reflows = reflowComments(cellLines, comment, column); + let reflows = reflowComments(cellLines, comment, column); + + // With a selection, only reflow the lines that overlap it; with just a + // cursor, reflow the whole cell. + const selections = editor.selections.filter((selection) => !selection.isEmpty); + if (selections.length > 0) { + reflows = reflows.filter((reflow) => + selections.some((selection) => { + const line = lineOffset + reflow.line; + // A selection ending at the start of a line doesn't include that line + const endLine = + selection.end.character === 0 && selection.end.line > selection.start.line + ? selection.end.line - 1 + : selection.end.line; + return line >= selection.start.line && line <= endLine; + }) + ); + } if (reflows.length === 0) { return; } @@ -83,9 +103,6 @@ class ReflowCommentInCellCommand implements Command { // 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 position to avoid range shifting issues [...reflows] diff --git a/apps/vscode/src/test/reflow.test.ts b/apps/vscode/src/test/reflow.test.ts index 552f08fa..733b04f2 100644 --- a/apps/vscode/src/test/reflow.test.ts +++ b/apps/vscode/src/test/reflow.test.ts @@ -163,6 +163,45 @@ suite("Reflow Comments in Cell", function () { ); }); + test("With a selection, only reflows the comment lines that overlap it", async function () { + const { doc, editor } = await openAndShowExamplesOutTextDocument("reflow.qmd"); + + // Select part of line 9 (the second long comment); line 7 also needs + // reflowing but doesn't overlap the selection + editor.selection = new vscode.Selection(9, 2, 9, 10); + await vscode.commands.executeCommand("quarto.reflowCommentInCell"); + + const lines = doc.getText().split("\n"); + assert.strictEqual( + lines[7], + "# It is a truth universally acknowledged, that a single man in possession of a good fortune must be in want of a wife." + ); + assert.strictEqual( + lines[9], + '# "My dear Mr. Bennet," said his lady to him one day, "have you heard that' + ); + assert.strictEqual(lines[10], "# Netherfield Park is let at last?\""); + }); + + test("A selection ending at the start of a line doesn't include that line", async function () { + const { doc, editor } = await openAndShowExamplesOutTextDocument("reflow.qmd"); + + // Select lines 7-8 by dragging to the start of line 9 + editor.selection = new vscode.Selection(7, 0, 9, 0); + await vscode.commands.executeCommand("quarto.reflowCommentInCell"); + + const lines = doc.getText().split("\n"); + assert.strictEqual( + lines[7], + "# It is a truth universally acknowledged, that a single man in possession of a" + ); + assert.strictEqual(lines[8], "# good fortune must be in want of a wife."); + assert.strictEqual( + lines[10], + '# "My dear Mr. Bennet," said his lady to him one day, "have you heard that Netherfield Park is let at last?"' + ); + }); + test("Leaves option directives and code untouched in the Python cell", async function () { const { doc, editor } = await openAndShowExamplesOutTextDocument("reflow.qmd"); From 28dde5a006985803aad65f5cbe44368dc44d423e Mon Sep 17 00:00:00 2001 From: Elliot Date: Wed, 19 Aug 2026 15:17:34 -0400 Subject: [PATCH 6/6] Update apps/vscode/package.json Co-authored-by: Julia Silge --- apps/vscode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index b936a92e..cd1ed78c 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -672,7 +672,7 @@ }, { "command": "quarto.reflowCommentInCell", - "when": "editorLangId == quarto && !editorHasSelection", + "when": "editorLangId == quarto", "group": "1_modification" }, {