Skip to content

Add support for SnippetTextEdit - #2884

Merged
jwortmann merged 3 commits into
sublimelsp:mainfrom
jwortmann:snippet-text-edit-2
Apr 29, 2026
Merged

jwortmann merged 3 commits into
sublimelsp:mainfrom
jwortmann:snippet-text-edit-2

Conversation

@jwortmann

@jwortmann jwortmann commented Apr 27, 2026 •

Copy link
Copy Markdown
Member

Supersedes #2426


Example:

snippetTextEdit.mp4
public class Main {
    public class SuperType {
        public SuperType(String name) {}
    }
    public class SubType extends SuperType {
    }
}

Here the inserted SnippetTextEdit has 3 tab stops, two of them with a placeholder text and the last one moving the caret to the end of the inserted text (actually that one is implicitly added, because the snippet text doesn't contain ${0}).

It can be tested with LSP-jdtls, and that is the only server I found which already supports SnippetTextEdit at the moment.
This requires additional setup:

Under

"workspaceEdit": {

you need to temporarily add the line

"resourceOperations": ["create", "rename", "delete"],  # pyright: ignore[reportAssignmentType]

because otherwise jdtls doesn't want to emit edits in the SnippetTextEdit format.

Also you need to create an override for the LSP-jdtls/modules/jdtls.py file, and adjust the initializationOptions, because LSP-jdtls ignores the initialization options from its own settings file. Then you need to add the line "snippetEditSupport": True, into the "extendedClientCapabilities" at https://github.com/sublimelsp/LSP-jdtls/blob/3866f45806d452d81617830062ac7fbd43f82d8b/modules/jdtls.py#L178-L202 and also change some of the other values to True (in case of the code example from above it is "generateConstructorsPromptSupport": True).

Edit: Oh and I forgot to mention that LSP-jdtls is quite broken in general. To make it work with a recent JDK version you need to change the downloaded server version in LSP-jdtls.sublime-settings, like

{
    "enabled": true,
    "version": "1.58.0",
    "settings": {
        "java.jdt.ls.java.home": "C:\\Program Files\\Java\\jdk-26.0.1"
    },
}

I think we should probably also remove the custom implementation for process_placeholders in

class LspApplyDocumentEditCommand(sublime_plugin.TextCommand):
that is specific to rust-analyzer, because SnippetTextEdit is now part of the official (3.18) LSP specs, so rust-analyzer should just switch to that instead (also I believe there are some bugs and inefficiencies in that implementation). But I haven't removed it in this PR yet.

Comment thread plugin/core/sessions.py
def on_workspace_apply_edit(self, params: ApplyWorkspaceEditParams) -> Promise[ApplyWorkspaceEditResult]:
return self.apply_workspace_edit_async(params.get('edit', {}), label=params.get('label')) \
.then(lambda _: {"applied": True})
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.

This is a "drive-by" change to add support for WorkspaceEditMetadata from the LSP 3.18 specs.

Comment thread plugin/edit.py
Comment on lines +157 to +165
def _get_region(self, edit: TextEdit | AnnotatedTextEdit | SnippetTextEdit) -> tuple[int, sublime.Region]:
range_ = edit['range']
start = parse_lsp_position(range_['start'])
end = parse_lsp_position(range_['end'])
region = sublime.Region(
self.view.text_point_utf16(*start, clamp_column=True),
self.view.text_point_utf16(*end, clamp_column=True)
)
return start[0], region

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.

There is function for that -

def range_to_region(lsp_range: Range, view: sublime.View) -> sublime.Region:

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.

That one is less efficient because it converts to custom Point objects first, and it would unnecessarily access the start twice from the range dict (because we also need start separately).

I used range_to_region initially, but then decided to align with the current implementation in LspApplyDocumentEditCommand:

LSP/plugin/edit.py

Lines 143 to 146 in 9a3c394

region = sublime.Region(
self.view.text_point_utf16(*start, clamp_column=True),
self.view.text_point_utf16(*end, clamp_column=True)
)

and

LSP/plugin/edit.py

Lines 158 to 161 in 9a3c394

self.view.sel().add(sublime.Region(
self.view.text_point_utf16(*placeholder_region[0], clamp_column=True),
self.view.text_point_utf16(*placeholder_region[1], clamp_column=True)
))

Comment thread plugin/edit.py
if scheme == "file" and line_content:
end_row, end_col_utf16 = parse_lsp_position(edit['range']['end'])
new_text_rows = edit['newText'].split('\n')
new_text = sublime.expand_variables(edit['snippet']['value'], {}) if is_snippet_text_edit(edit) else \

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.

Can you explain why are we calling expand_variables on a snippet?
I would expect snippet to contain tab stops, not variables to expand.

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.

expand_variables (with empty variables) automatically eliminates the placeholders. So this converts the SnippetTextEdit into the 'newText' from a regular TextEdit, which is needed if we don't want to insert it as a snippet (e.g. if not focused view, etc.).

@jwortmann jwortmann Apr 29, 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.

It's basically a built-in and more powerful variant of what you re-invented with custom regex and string replacement at

LSP/plugin/edit.py

Lines 174 to 176 in 9a3c394

if match := re.search(self.re_placeholder, replacement):
placeholder = match.group(2) or ''
new_replacement = replacement.replace(match.group(0), placeholder)

>>> sublime.expand_variables('Hello, ${1:name}!$0', {})
'Hello, name!'

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.

Also more buggy: #2625

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 the only places where two consecutive backslashes are used in source code would be in LaTeX and also in strings with Windows paths. But I imagine that would be quite rare to happen in WorkspaceEdits / SnippetTextEdits. So I would say we should keep that for now, and if it turns out that this bug regularly happens in practice, we could maybe think about implementing a custom patched version of sublime.expand_variables.

@jwortmann
jwortmann merged commit def3cf8 into sublimelsp:main Apr 29, 2026
8 checks passed
@jwortmann
jwortmann deleted the snippet-text-edit-2 branch April 29, 2026 22:12
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