Skip to content

Return correct result for workspace/applyEdit request - #2896

Merged
rwols merged 4 commits into
sublimelsp:mainfrom
jwortmann:workspace-edit-result
May 14, 2026
Merged

rwols merged 4 commits into
sublimelsp:mainfrom
jwortmann:workspace-edit-result

Conversation

@jwortmann

Copy link
Copy Markdown
Member

When handling a workspace/applyEdit request from the server, the client must return a ApplyWorkspaceEditResult.

Currently, we unconditionally return "applied": true, even when the WorkspaceEdit cannot be applied correctly (e.g. due to failed to open view for URI, or view version doesn't match a given version in the WorkspaceEdit):

).then(lambda _: {'applied': True})

Apart from that, we declare

"failureHandling": FailureHandlingKind.Abort,

for handling WorkspaceEdits, which means that (https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#failureHandlingKind)

	/**
	 * Applying the workspace change is simply aborted if one of the changes
	 * provided fails. All operations executed before the failing operation
	 * stay executed.
	 */
	export const Abort: FailureHandlingKind = 'abort';

But in reality, we simply continue with further changes even if one of the change failed:

LSP/plugin/core/sessions.py

Lines 1674 to 1680 in ced62c9

for uri, (edits, label, view_version) in changes.items():
view_state_actions = self._get_view_state_actions(uri, auto_save)
promises.append(
self.open_uri_async(uri)
.then(partial(handle_view, edits, label, view_version, uri, view_state_actions))
)
return Promise.all(promises) \

So the actual behavior isn't what we declared in the client capabilities as our failure handling strategy. Also I think this async implementation (via Promise.all) cannot work if support for resource operations (create / rename) should be implemented later at some point (#2706), because we cannot run a TextDocumentEdit before a file for the corresponding URI has been created or renamed. This means that if the WorkspaceEdit contains documentChanges, we have to apply them sequentially.

This PR should fix that, but only for WorkspaceEdits from the workspace/applyEdit request. Other WorkspaceEdits, for example from code actions or from a textDocument/rename request still use the previous (current) implementation. For those it would require some more refactorings, because they currently return a custom WorkspaceEditSummary which is used to display the number of changes and affected files in the UI (in case of rename).


Also this PR still ignores the needsConfirmation flag from the ChangeAnnotation of AnnotatedTextEdit:

	/**
	 * A flag which indicates that user confirmation is needed
	 * before applying the change.
	 */
	needsConfirmation?: boolean;

If we want to support that, for example with a sublime.ok_cancel_dialog, then probably more considerations will be required to make the code wait for the user choice.

Comment thread plugin/core/edit.py
deleted_files: NotRequired[int]


# TODO: change return type to TypeIs[SnippetTextEdit] when properly supported by Pyright

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've read in some Pyright issue that (unlike TypeGuard) TypeIs is not supported for TypedDicts, because TypedDicts are "structural types" (not sure what that means exactly). So it will probably never be possible to use TypeIs for the LSP types, at least when using Pyright.

Comment thread plugin/core/sessions.py
document_changes, change_annotations, index + 1, label, auto_save)

try:
document_change = document_changes.pop(0)

@jwortmann jwortmann May 12, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Note that this seems to cause the documentChanges of a WorkspaceEdit from a workspace/applyEdit request being shown as an empty array in the LSP log panel. But I would consider this as a bug in our logging functionality (I opened related issue #2897).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah, we can log it from the transports level probably. Although, the problem with logging the plain JSON is that you can't easily hide large payloads while still printing which request/notification it is (and the request ID). Maybe there's a way to deal with that.

@jwortmann

jwortmann commented May 12, 2026 •

Copy link
Copy Markdown
Member Author

I've tested with different code actions from LSP-julia, which don't contain a WorkspaceEdit but trigger server commands via workspace/executeCommand, which in turn trigger the workspace/applyEdit requests from the server.

(Although those WorkspaceEdits only contain a single TextDocumentEdit, and also obviously they are all applied successfully. I don't know how we could test a WorkspaceEdit that does fail in practice - but perhaps we can write a test for that.)

@jwortmann
jwortmann marked this pull request as ready for review May 12, 2026 12:16
Comment thread plugin/edit.py
Comment on lines -117 to -119
if version is not None and version != self.view.change_count():
debug('ignoring edit due to non-matching document version')
return

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think it would be nice to keep this check here (because it is executed on the UI thread) to ensure that there can't be any race condition when applying this TextDocumentEdit while typing at the same time. But I don't think there is any good way to signalize from the command back to the calling method that the edit has failed, and we need that for stopping the execution and returning back the failureReason. Therefore I moved this check into the functions which call this command.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have some ideas for this once the feat/asyncio branch is merged in main. But they're still a bit fuzzy.

@jwortmann jwortmann May 14, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I hope with asyncio we can also rewrite the recursive function as a simple for-loop which awaits each document change.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Indeed. But for now I kept the recursion in my branch.

Comment thread tests/test_server_requests.py Resolved
Comment thread plugin/core/sessions.py
).then(lambda _: {'applied': True})
edit = params['edit']
label = params.get('label')
is_refactoring = metadata.get('isRefactoring', False) if (metadata := params.get('metadata')) else False

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These type of statements involving the := operator where the value is used before the assignment are getting into "too clever" territory :) but I'll allow it.

Something more plain and dumb is more clear to read, IMO:

metadata = params.get('metadata')
is_refactoring = metadata is not None and metadata.get('isRefactoring, False)

.

or even

is_refactoring = params.get('metadata', {}).get('isRefactoring', False)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I initially used the

is_refactoring = params.get('metadata', {}).get('isRefactoring', False)

when I added it recently, but the change I did here with the if-condition is a micro optimization that doesn't require to create an empty dict in most cases and then do the membership test (which involves calculating the hash value, I believe) for 'isRefactoring'. So the new code should be a little bit more efficient. In general I quite like the := operator and think it often results in more readable code. But okay, I'll try not to push it too far in the future :-)

Comment thread plugin/edit.py
Comment on lines -117 to -119
if version is not None and version != self.view.change_count():
debug('ignoring edit due to non-matching document version')
return

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have some ideas for this once the feat/asyncio branch is merged in main. But they're still a bit fuzzy.

Comment thread plugin/core/sessions.py
document_changes, change_annotations, index + 1, label, auto_save)

try:
document_change = document_changes.pop(0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah, we can log it from the transports level probably. Although, the problem with logging the plain JSON is that you can't easily hide large payloads while still printing which request/notification it is (and the request ID). Maybe there's a way to deal with that.

@rwols
rwols merged commit 7ea4e02 into sublimelsp:main May 14, 2026
8 checks passed
@jwortmann
jwortmann deleted the workspace-edit-result branch May 14, 2026 10:28
@jwortmann

Copy link
Copy Markdown
Member Author

As a sidenote, I think we had an unspoken convention over the past time that for non-trivial pull requests, once it is approved, the PR author is the one who merges it. I don't think I had anything particular to add here, but in general the author probably knows best when it is fully ready, and sometimes you want to look over it again later, or still have something in your mind that you want to add. I think then it's a bit easier to handle and you don't always need to mark every PR as a draft PR until it gets merged eventually.

Comment thread plugin/core/edit.py Resolved
@rwols

rwols commented May 14, 2026

Copy link
Copy Markdown
Member

As a sidenote, I think we had an unspoken convention over the past time that for non-trivial pull requests, once it is approved, the PR author is the one who merges it.

OK, I'll keep this in mind. In this particular case, I was going to make changes related to the document changes, so I wanted to make sure this was merged before I got to that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants