diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index fa4312f9..7620219b 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 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`) (). - Make "insert cell" split the cell when cursor is inside, or insert a cell above when the cursor is at the top (). - Add highlighting for option comments in code cells (). diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 5f290fde..cd1ed78c 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 Comments 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", + "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 Comments 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 { + 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); + + // The `+ 1` skips the opening fence line. + const lineOffset = block.range.start.line + 1; + + const cellLines = lines(codeForExecutableLanguageBlock(block, false)); + 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; + } + + // Use the document's line ending to avoid introducing mixed EOL in CRLF files. + const eol = document.eol === EndOfLine.CRLF ? "\r\n" : "\n"; + + await editor.edit((editBuilder) => { + // Sort by descending position to avoid range shifting issues + [...reflows] + .sort((a, b) => b.line - a.line) + .forEach((reflow) => { + const range = new Range( + new Position(lineOffset + reflow.line, 0), + document.lineAt(lineOffset + reflow.line).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 { + /** The replaced line (0-based, relative to the cell body). */ + line: number; + /** Replacement lines. */ + newLines: string[]; +} + +interface WrappableComment { + indent: string; + prefix: string; + content: string; +} + +/** + * 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. + * + * 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 line that was split. + */ +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): WrappableComment | 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); + const prefix = prefixPattern.exec(trimmed)![1]; + const rest = trimmed.slice(prefix.length); + if (rest !== "" && !/^[ \t]/.test(rest)) { + // The prefix runs straight into other characters (`#!/usr/bin/env`, + // `#--- foo`): leave the line verbatim. + return undefined; + } + const content = rest.trim(); + 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[] = []; + cellLines.forEach((raw, line) => { + if (raw.length <= column) { + return; + } + const parsed = parseLine(raw); + if (!parsed) { + return; + } + 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 wrapComment(comment: WrappableComment, column: number): string[] { + const linePrefix = comment.indent + comment.prefix + " "; + const width = Math.max(1, column - linePrefix.length); + const words = comment.content.split(/\s+/); + 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..733b04f2 --- /dev/null +++ b/apps/vscode/src/test/reflow.test.ts @@ -0,0 +1,250 @@ +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 Comments in Cell", function () { + + suite("reflowComments", function () { + test("Splits a long comment at the column", function () { + const reflows = reflowComments( + ["# aaa bbb ccc ddd", "x <- 1"], + "#", + 10 + ); + assert.strictEqual(reflows.length, 1); + assert.deepStrictEqual(reflows[0], { + line: 0, + newLines: ["# aaa bbb", "# ccc ddd"], + }); + }); + + test("Never joins short comment lines", function () { + const reflows = reflowComments(["# aaa", "# bbb", "# ccc"], "#", 80); + assert.deepStrictEqual(reflows, []); + }); + + test("Returns no edits when comments already fit", function () { + const reflows = reflowComments(["# aaa bbb", "x <- 1"], "#", 80); + assert.deepStrictEqual(reflows, []); + }); + + 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( + ["#!/usr/bin/env bash -o pipefail", "#--- not a wrappable comment"], + "#", + 10 + ); + 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].line, 2); + }); + + 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"], + "#", + 10 + ); + assert.strictEqual(reflows.length, 2); + assert.deepStrictEqual(reflows[0], { + line: 0, + newLines: ["# aaa bbb", "# ccc"], + }); + assert.deepStrictEqual(reflows[1], { + line: 3, + 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], { + line: 1, + newLines: ["# aaa bbb", "# ccc"], + }); + }); + + test("Preserves indentation and extended prefixes", function () { + const reflows = reflowComments( + [" # aaa bbb ccc", "#' roxygen docs stay wrapped with their prefix"], + "#", + 12 + ); + 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 () { + 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("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"); + + // 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; + } + }); + }); +});