Skip to content

Commit 3bc3579

Browse files
Copilotntotten
andauthored
Add code action provider for formatting with Prettier (#3863)
* Initial plan * Initial exploration and planning Co-authored-by: ntotten <[email protected]> * Add code action provider for formatting with Prettier Co-authored-by: ntotten <[email protected]> * Add documentation for code actions on save Co-authored-by: ntotten <[email protected]> * Add test for Prettier code action provider Co-authored-by: ntotten <[email protected]> * Fix code action test to use in-memory documents Co-authored-by: ntotten <[email protected]> * Address code review feedback: extract shared test constants Co-authored-by: ntotten <[email protected]> * Update CHANGELOG with code action feature Co-authored-by: ntotten <[email protected]> * Fix Windows test failure by normalizing line endings Co-authored-by: ntotten <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: ntotten <[email protected]> Co-authored-by: Nathan Totten <[email protected]>
1 parent 2984cef commit 3bc3579

File tree

6 files changed

+222
-16
lines changed

6 files changed

+222
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to the "prettier-vscode" extension will be documented in thi
1414
- Fixed parser detection fallback when using plugins with Prettier v3
1515
- Added new Prettier v3 options: `objectWrap`, `experimentalOperatorPosition`
1616
- Added support for TypeScript config files (`.prettierrc.ts`, `.prettierrc.cts`, `.prettierrc.mts`, `prettier.config.ts`, etc.) introduced in Prettier 3.5.0 - Thanks to [@dr2009](https://github.com/dr2009)
17+
- Added `source.fixAll.prettier` code action for use with `editor.codeActionsOnSave` to run Prettier before other formatters like ESLint (#1277)
1718
- Fixed issue where unnecessary TextEdits were applied when document was already formatted, which could cause spurious changes or cursor positioning issues (#3232)
1819

1920
## [11.0.0]

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,31 @@ If you would like to format a document that is configured to be ignored by Prett
211211

212212
The recommended way of integrating with linters is to let Prettier do the formatting and configure the linter to not deal with formatting rules. You can find instructions on how to configure each linter on the Prettier docs site. You can then use each of the linting extensions as you normally would. For details refer to the [Prettier documentation](https://prettier.io/docs/en/integrating-with-linters.html).
213213

214+
### Using Code Actions on Save
215+
216+
You can use VS Code's `editor.codeActionsOnSave` to run Prettier before other formatters like ESLint. This is useful when you want to format with Prettier first and then apply ESLint fixes.
217+
218+
```jsonc
219+
// .vscode/settings.json
220+
{
221+
"editor.codeActionsOnSave": {
222+
"source.fixAll.prettier": "explicit",
223+
},
224+
}
225+
```
226+
227+
You can also combine Prettier with ESLint:
228+
229+
```jsonc
230+
// .vscode/settings.json
231+
{
232+
"editor.codeActionsOnSave": {
233+
"source.fixAll.prettier": "explicit",
234+
"source.fixAll.eslint": "explicit",
235+
},
236+
}
237+
```
238+
214239
## Workspace Trust
215240

216241
This extension utilizes VS Code [Workspace Trust](https://code.visualstudio.com/docs/editor/workspace-trust) features. When this extension is run on an untrusted workspace, it will only use the built in version of prettier. No plugins, local, or global modules will be supported. Additionally, certain settings are also restricted - see each setting for details.

src/PrettierCodeActionProvider.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {
2+
CancellationToken,
3+
CodeAction,
4+
CodeActionContext,
5+
CodeActionKind,
6+
CodeActionProvider,
7+
Range,
8+
TextDocument,
9+
TextEdit,
10+
WorkspaceEdit,
11+
} from "vscode";
12+
import { ExtensionFormattingOptions } from "./types.js";
13+
14+
/**
15+
* Provides code actions for formatting with Prettier.
16+
* This enables using Prettier with codeActionsOnSave.
17+
*/
18+
export class PrettierCodeActionProvider implements CodeActionProvider {
19+
public static readonly providedCodeActionKinds = [
20+
CodeActionKind.SourceFixAll.append("prettier"),
21+
];
22+
23+
constructor(
24+
private provideEdits: (
25+
document: TextDocument,
26+
options: ExtensionFormattingOptions,
27+
) => Promise<TextEdit[]>,
28+
) {}
29+
30+
public async provideCodeActions(
31+
document: TextDocument,
32+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
33+
range: Range,
34+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
35+
context: CodeActionContext,
36+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
37+
token: CancellationToken,
38+
): Promise<CodeAction[]> {
39+
const edits = await this.provideEdits(document, {
40+
force: false,
41+
});
42+
43+
if (edits.length === 0) {
44+
return [];
45+
}
46+
47+
const action = new CodeAction(
48+
"Format with Prettier",
49+
CodeActionKind.SourceFixAll.append("prettier"),
50+
);
51+
const workspaceEdit = new WorkspaceEdit();
52+
workspaceEdit.set(document.uri, edits);
53+
action.edit = workspaceEdit;
54+
55+
return [action];
56+
}
57+
}

src/PrettierEditService.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getParserFromLanguageId } from "./utils/get-parser-from-language.js";
1616
import { LoggingService } from "./LoggingService.js";
1717
import { RESTART_TO_ENABLE } from "./message.js";
1818
import { PrettierEditProvider } from "./PrettierEditProvider.js";
19+
import { PrettierCodeActionProvider } from "./PrettierCodeActionProvider.js";
1920
import { FormatterStatus, StatusBar } from "./StatusBar.js";
2021
import {
2122
ExtensionFormattingOptions,
@@ -132,6 +133,7 @@ const PRETTIER_CONFIG_FILES = [
132133
export default class PrettierEditService implements Disposable {
133134
private formatterHandler: undefined | Disposable;
134135
private rangeFormatterHandler: undefined | Disposable;
136+
private codeActionHandler: undefined | Disposable;
135137
private registeredWorkspaces = new Set<string>();
136138

137139
private allLanguages: string[] = [];
@@ -207,7 +209,9 @@ export default class PrettierEditService implements Disposable {
207209
return;
208210
}
209211
if (edits.length > 1) {
210-
this.loggingService.logWarning(`Unexpected multiple edits (${edits.length}), expected 0 or 1`);
212+
this.loggingService.logWarning(
213+
`Unexpected multiple edits (${edits.length}), expected 0 or 1`,
214+
);
211215
return;
212216
}
213217

@@ -316,8 +320,10 @@ export default class PrettierEditService implements Disposable {
316320
this.moduleResolver.dispose();
317321
this.formatterHandler?.dispose();
318322
this.rangeFormatterHandler?.dispose();
323+
this.codeActionHandler?.dispose();
319324
this.formatterHandler = undefined;
320325
this.rangeFormatterHandler = undefined;
326+
this.codeActionHandler = undefined;
321327
};
322328

323329
private registerDocumentFormatEditorProviders({
@@ -326,6 +332,9 @@ export default class PrettierEditService implements Disposable {
326332
}: ISelectors) {
327333
this.dispose();
328334
const editProvider = new PrettierEditProvider(this.provideEdits);
335+
const codeActionProvider = new PrettierCodeActionProvider(
336+
this.provideEdits,
337+
);
329338
this.rangeFormatterHandler =
330339
languages.registerDocumentRangeFormattingEditProvider(
331340
rangeLanguageSelector,
@@ -335,6 +344,14 @@ export default class PrettierEditService implements Disposable {
335344
languageSelector,
336345
editProvider,
337346
);
347+
this.codeActionHandler = languages.registerCodeActionsProvider(
348+
languageSelector,
349+
codeActionProvider,
350+
{
351+
providedCodeActionKinds:
352+
PrettierCodeActionProvider.providedCodeActionKinds,
353+
},
354+
);
338355
}
339356

340357
/**
@@ -453,20 +470,25 @@ export default class PrettierEditService implements Disposable {
453470
const edit = this.minimalEdit(document, result);
454471
if (!edit) {
455472
// Document is already formatted, no changes needed
456-
this.loggingService.logDebug("Document is already formatted, no changes needed.");
473+
this.loggingService.logDebug(
474+
"Document is already formatted, no changes needed.",
475+
);
457476
return [];
458477
}
459478
return [edit];
460479
};
461480

462-
private minimalEdit(document: TextDocument, string1: string): TextEdit | null {
481+
private minimalEdit(
482+
document: TextDocument,
483+
string1: string,
484+
): TextEdit | null {
463485
const string0 = document.getText();
464-
486+
465487
// Quick check: if strings are identical, no edit needed
466488
if (string0 === string1) {
467489
return null;
468490
}
469-
491+
470492
// length of common prefix
471493
let i = 0;
472494
while (

src/test/suite/codeAction.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import * as assert from "assert";
2+
import * as vscode from "vscode";
3+
import { ensureExtensionActivated } from "./testUtils.js";
4+
5+
describe("Test Prettier Code Actions", () => {
6+
const unformattedCode = `const x = { a: 1, b: 2 }`;
7+
8+
before(async () => {
9+
await ensureExtensionActivated();
10+
});
11+
12+
it("provides source.fixAll.prettier code action", async () => {
13+
const doc = await vscode.workspace.openTextDocument({
14+
content: unformattedCode,
15+
language: "javascript",
16+
});
17+
await vscode.window.showTextDocument(doc);
18+
19+
// Get code actions for the document
20+
const codeActions = await vscode.commands.executeCommand<
21+
vscode.CodeAction[]
22+
>(
23+
"vscode.executeCodeActionProvider",
24+
doc.uri,
25+
new vscode.Range(0, 0, doc.lineCount, 0),
26+
);
27+
28+
// Find the Prettier code action
29+
const prettierAction = codeActions?.find(
30+
(action) => action.kind?.value === "source.fixAll.prettier",
31+
);
32+
33+
assert.ok(prettierAction, "Prettier code action should be available");
34+
assert.equal(
35+
prettierAction?.title,
36+
"Format with Prettier",
37+
"Code action should have correct title",
38+
);
39+
});
40+
41+
it("formats document using code action", async () => {
42+
const doc = await vscode.workspace.openTextDocument({
43+
content: unformattedCode,
44+
language: "javascript",
45+
});
46+
await vscode.window.showTextDocument(doc);
47+
48+
// Get code actions for the document
49+
const codeActions = await vscode.commands.executeCommand<
50+
vscode.CodeAction[]
51+
>(
52+
"vscode.executeCodeActionProvider",
53+
doc.uri,
54+
new vscode.Range(0, 0, doc.lineCount, 0),
55+
);
56+
57+
// Find and apply the Prettier code action
58+
const prettierAction = codeActions?.find(
59+
(action) => action.kind?.value === "source.fixAll.prettier",
60+
);
61+
62+
assert.ok(prettierAction, "Prettier code action should be available");
63+
64+
// Apply the code action
65+
if (prettierAction?.edit) {
66+
await vscode.workspace.applyEdit(prettierAction.edit);
67+
}
68+
69+
const formattedText = doc.getText();
70+
71+
// Verify the document was formatted correctly
72+
// Normalize line endings for cross-platform compatibility
73+
const normalizedFormatted = formattedText.replace(/\r\n/g, "\n");
74+
const expectedNormalized = `const x = { a: 1, b: 2 };\n`;
75+
76+
assert.equal(
77+
normalizedFormatted,
78+
expectedNormalized,
79+
"Document should be formatted correctly after applying code action",
80+
);
81+
});
82+
});

src/test/suite/format.test.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,28 +56,47 @@ describe("Test format Document", () => {
5656
it("formats Markdown without adding extra empty lines", async () => {
5757
// This test checks for the issue where formatting MD files multiple times
5858
// would add empty lines at the end (up to 2 empty lines)
59-
const { actual: firstFormat } = await format("project", "formatTest/ugly.md");
60-
59+
const { actual: firstFormat } = await format(
60+
"project",
61+
"formatTest/ugly.md",
62+
);
63+
6164
// Count trailing newlines
6265
const countTrailingNewlines = (str: string) => {
6366
let count = 0;
6467
for (let i = str.length - 1; i >= 0; i--) {
65-
if (str[i] === '\n') count++;
66-
else if (str[i] === '\r') continue; // Skip \r in CRLF
68+
if (str[i] === "\n") count++;
69+
else if (str[i] === "\r")
70+
continue; // Skip \r in CRLF
6771
else break;
6872
}
6973
return count;
7074
};
71-
75+
7276
const firstCount = countTrailingNewlines(firstFormat);
73-
77+
7478
// Format a second time via the extension to verify idempotency
75-
const { actual: secondFormat } = await format("project", "formatTest/ugly.md");
79+
const { actual: secondFormat } = await format(
80+
"project",
81+
"formatTest/ugly.md",
82+
);
7683
const secondCount = countTrailingNewlines(secondFormat);
77-
84+
7885
// Should have exactly 1 trailing newline after both formats
79-
assert.equal(firstCount, 1, "First format should have exactly 1 trailing newline");
80-
assert.equal(secondCount, 1, "Second format should have exactly 1 trailing newline");
81-
assert.equal(firstFormat, secondFormat, "Formatting via extension should be idempotent");
86+
assert.equal(
87+
firstCount,
88+
1,
89+
"First format should have exactly 1 trailing newline",
90+
);
91+
assert.equal(
92+
secondCount,
93+
1,
94+
"Second format should have exactly 1 trailing newline",
95+
);
96+
assert.equal(
97+
firstFormat,
98+
secondFormat,
99+
"Formatting via extension should be idempotent",
100+
);
82101
});
83102
});

0 commit comments

Comments
 (0)