Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to the "prettier-vscode" extension will be documented in thi

## [Unreleased]

- Preserve cursor position after formatting using Prettier's `formatWithCursor()` API (#3939)

## [12.2.0]

- Fixed `source.fixAll.prettier` code action running even when `editor.defaultFormatter` was set to a different extension (#3908). The code action now respects the user's formatter choice and only runs when Prettier is the default formatter or when `source.fixAll.prettier` is explicitly enabled.
Expand Down
11 changes: 11 additions & 0 deletions src/ModuleResolverWeb.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
PrettierCursorOptions,
PrettierCursorResult,
PrettierFileInfoOptions,
PrettierFileInfoResult,
PrettierSupportLanguage,
Expand Down Expand Up @@ -70,6 +72,15 @@ export class ModuleResolver implements ModuleResolverInterface {
format: async (source: string, options: PrettierOptions) => {
return prettierStandalone.format(source, { ...options, plugins });
},
formatWithCursor: async (
source: string,
options: PrettierCursorOptions,
): Promise<PrettierCursorResult> => {
return prettierStandalone.formatWithCursor(source, {
...options,
plugins,
});
},
resolveConfigFile: async (): Promise<string | null> => {
// Config file resolution not supported in browser
return null;
Expand Down
18 changes: 17 additions & 1 deletion src/PrettierDynamicInstance.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import * as path from "path";
import { pathToFileURL } from "url";
import type { FileInfoOptions, Options, ResolveConfigOptions } from "prettier";
import type {
CursorOptions,
CursorResult,
FileInfoOptions,
Options,
ResolveConfigOptions,
} from "prettier";
import type {
PrettierFileInfoResult,
PrettierPlugin,
Expand Down Expand Up @@ -62,6 +68,16 @@ export const PrettierDynamicInstance: PrettierInstanceConstructor = class Pretti
return this.prettierModule!.format(source, options);
}

public async formatWithCursor(
source: string,
options: CursorOptions,
): Promise<CursorResult> {
if (!this.prettierModule) {
await this.import();
}
return this.prettierModule!.formatWithCursor(source, options);
}

public async getFileInfo(
filePath: string,
fileInfoOptions?: FileInfoOptions | undefined,
Expand Down
97 changes: 90 additions & 7 deletions src/PrettierEditService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DocumentFilter,
languages,
Range,
Selection,
TextDocument,
TextEdit,
TextEditor,
Expand All @@ -22,6 +23,8 @@ import {
ExtensionFormattingOptions,
ModuleResolverInterface,
PrettierBuiltInParserName,
PrettierCursorOptions,
PrettierCursorResult,
PrettierFileInfoResult,
PrettierInstance,
PrettierModule,
Expand Down Expand Up @@ -104,6 +107,11 @@ interface ISelectors {
languageSelector: ReadonlyArray<DocumentFilter>;
}

interface FormatResult {
formatted: string;
cursorOffset: number;
}

export default class PrettierEditService implements Disposable {
private formatterHandler: undefined | Disposable;
private rangeFormatterHandler: undefined | Disposable;
Expand Down Expand Up @@ -448,21 +456,58 @@ export default class PrettierEditService implements Disposable {
options: ExtensionFormattingOptions,
): Promise<TextEdit[]> => {
const startTime = new Date().getTime();
const result = await this.format(document.getText(), document, options);

// Get cursor offset from active editor if available and not doing range formatting
const editor = window.activeTextEditor;
const isRangeFormatting =
options.rangeStart !== undefined && options.rangeEnd !== undefined;
let cursorOffset: number | undefined;

if (editor && editor.document === document && !isRangeFormatting) {
cursorOffset = document.offsetAt(editor.selection.active);
}

const result = await this.format(
document.getText(),
document,
options,
cursorOffset,
);
if (!result) {
// No edits happened, return never so VS Code can try other formatters
return [];
}
const duration = new Date().getTime() - startTime;
this.loggingService.logInfo(`Formatting completed in ${duration}ms.`);
const edit = this.minimalEdit(document, result);
const edit = this.minimalEdit(document, result.formatted);
if (!edit) {
// Document is already formatted, no changes needed
this.loggingService.logDebug(
"Document is already formatted, no changes needed.",
);
return [];
}

// Schedule cursor repositioning after VS Code applies the edit
// We use setImmediate to run after the current event loop completes
if (
editor &&
editor.document === document &&
!isRangeFormatting &&
result.cursorOffset >= 0
) {
setImmediate(() => {
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setImmediate function is not available in browser environments, but this extension supports both Node.js and browser contexts (as indicated by the "browser" field in package.json). This will cause a runtime error when the extension runs in vscode.dev or other browser-based VS Code instances.

Consider using setTimeout(() => {...}, 0) instead, which works in both Node.js and browser environments and provides similar behavior of deferring execution to the next event loop tick.

Copilot uses AI. Check for mistakes.
// Verify the editor is still active and document hasn't changed
if (
window.activeTextEditor === editor &&
editor.document === document
) {
const newPosition = document.positionAt(result.cursorOffset);
editor.selection = new Selection(newPosition, newPosition);
}
});
}
Comment on lines +491 to +509
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new cursor position preservation feature lacks test coverage. Consider adding a test that:

  1. Opens a document with unformatted code
  2. Places the cursor at a specific position
  3. Triggers formatting
  4. Verifies the cursor is at the expected position in the formatted output

This is especially important since this feature involves timing-sensitive operations with setImmediate and the interaction between VS Code's edit application and cursor repositioning.

Copilot uses AI. Check for mistakes.

return [edit];
};

Expand Down Expand Up @@ -505,14 +550,17 @@ export default class PrettierEditService implements Disposable {
/**
* Format the given text with user's configuration.
* @param text Text to format
* @param path formatting file's path
* @returns {string} formatted text
* @param doc TextDocument being formatted
* @param options Formatting options
* @param cursorOffset Optional cursor offset for cursor preservation
* @returns FormatResult with formatted text and new cursor offset, or undefined if formatting failed
*/
private async format(
text: string,
doc: TextDocument,
options: ExtensionFormattingOptions,
): Promise<string | undefined> {
cursorOffset?: number,
): Promise<FormatResult | undefined> {
const { fileName, uri, languageId } = doc;

this.loggingService.logInfo(`Formatting ${uri}`);
Expand Down Expand Up @@ -631,18 +679,53 @@ export default class PrettierEditService implements Disposable {
this.loggingService.logInfo("Prettier Options:", prettierOptions);

try {
// Use formatWithCursor if we have a cursor offset to preserve cursor position
if (cursorOffset !== undefined) {
const cursorOptions: PrettierCursorOptions = {
...prettierOptions,
cursorOffset,
};

// Check if formatWithCursor is available (it should be for all modern Prettier versions)
if ("formatWithCursor" in prettierInstance) {
try {
const result: PrettierCursorResult =
await prettierInstance.formatWithCursor(text, cursorOptions);
this.statusBar.update(FormatterStatus.Success);
return {
formatted: result.formatted,
cursorOffset: result.cursorOffset,
};
} catch (cursorError) {
// formatWithCursor can fail with some plugins that don't implement locStart/locEnd
// Fall back to regular format() in this case
this.loggingService.logDebug(
"formatWithCursor failed, falling back to format()",
cursorError,
);
}
}
}

// Fallback to regular format() if no cursor offset or formatWithCursor not available/failed
const formattedText = await prettierInstance.format(
text,
prettierOptions,
);
this.statusBar.update(FormatterStatus.Success);

return formattedText;
return {
formatted: formattedText,
cursorOffset: -1, // Indicate that cursor position is unknown
};
} catch (error) {
this.loggingService.logError("Error formatting document.", error);
this.statusBar.update(FormatterStatus.Error);

return text;
return {
formatted: text,
cursorOffset: cursorOffset ?? -1,
};
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ export type PrettierBuiltInParserName = prettier.BuiltInParserName;
export type PrettierResolveConfigOptions = prettier.ResolveConfigOptions;
export type PrettierOptions = prettier.Options;
export type PrettierFileInfoOptions = prettier.FileInfoOptions;
export type PrettierCursorOptions = prettier.CursorOptions;
export type PrettierCursorResult = prettier.CursorResult;

export type PrettierPlugin = prettier.Plugin<any> | string | URL;

export interface PrettierInstance {
version: string | null;
import(): Promise<string>;
format(source: string, options?: PrettierOptions): Promise<string>;
formatWithCursor(
source: string,
options: PrettierCursorOptions,
): Promise<PrettierCursorResult>;
getFileInfo(
filePath: string,
fileInfoOptions?: PrettierFileInfoOptions,
Expand All @@ -41,6 +47,10 @@ export interface PrettierInstanceConstructor {
export type PrettierModule = {
version: string;
format(source: string, options?: PrettierOptions): Promise<string>;
formatWithCursor(
source: string,
options: PrettierCursorOptions,
): Promise<PrettierCursorResult>;
getSupportInfo(options?: {
plugins?: Array<string | PrettierPlugin>;
}): Promise<{ languages: PrettierSupportLanguage[] }>;
Expand Down
Loading