Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow disabling of editor templates when accepting method/function invocation completions (fixes #718) #744

Merged
merged 5 commits into from
Jan 15, 2025

Conversation

SCWells72
Copy link
Contributor

This is a fresh branch-based PR as a replacmeent for #738.

As stated/shown in #718, the editor template that is currently started when accepting a code completion that is a method/function invocation with a single argument is quite disruptive to the standard editor flow. This PR adds a new client configuration option, completion.useTemplateForSingleArgument, that, when disabled (enabled by default for backward-compatibility), instead just places the caret into the invocation arguments so that the user can populate them as they would in any of JetBrains' natively-supported language editors. I've also updated all existing language server definition templates to disable editor templates in this case so that the "default" behavior for newly-created language server definitions is the smoother/more natural editor flow, but existing language server definitions will not see a change in behavior.

UPDATE: This has now been generalized to allow disabling editor templates for any invocation-only snippet. An invocation-only snippet is one where all non-end ($0) variables are found as a comma-/whitespace-delimited list in invocation parentheses. If other invocation patterns are found in LSP languages, recognition support can be added for them as well.

The configuration option is now completion.useTemplateForInvocationOnlySnippet to reflect this more general behavior. As before, it is disabled by default in code but enabled by default in the bundled language server templates.

This also properly handles the situation of a single no-value variable snippet by forgoing an editor template and moving to the correct caret position for that variable with no need for any client config change.

@@ -100,14 +114,16 @@ public void handleInsert(@NotNull InsertionContext context) {
updateCompletionItemFromResolved();
Template template = null;
if (item.getInsertTextFormat() == InsertTextFormat.Snippet) {
// Adjust the snippet content as appropriate based on client config
String snippetContent = adjustSnippetContent(getInsertText());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now we adjust the LSP-provided snippet before parsing it into a template if appropriate based on client config and the snippet contents.

// Get the indentation settings
LspSnippetIndentOptions indentOptions = CompletionProposalTools.createLspIndentOptions(snippetContent, file);
// Load the insert text to build:
// - an IJ Template instance which will take care of replacement of placeholders
// - the insert text without placeholders
template = SnippetTemplateFactory.createTemplate(snippetContent, context.getProject(), name -> getVariableValue(name), indentOptions);
template = SnippetTemplateFactory.createTemplate(snippetContent, context.getProject(), this::getVariableValue, indentOptions);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just changed to a method reference. Same behavior.

@@ -116,7 +132,12 @@ public void handleInsert(@NotNull InsertionContext context) {
// Apply all text edits
apply(context.getDocument(), context.getOffset(CompletionInitializationContext.SELECTION_END_OFFSET));

if (shouldStartTemplate(template)) {
// Just move to the first tab stop if there's a single invocation argument with no default value
if (shouldMoveToFirstTabStop(template)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now we only move to the first tab stop for a template with a single non-end variable that has no value. That happens independent of client config because it doesn't make sense to start an editor template for a single tab stop with no default value. Instead just place the caret in that same location and let the user type what they need.

@@ -155,12 +176,57 @@ private void updateCompletionItemFromResolved() {
this.item = resolved;
}

@NotNull
private String adjustSnippetContent(@NotNull String snippetContent) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hopefully this method is pretty self-explanatory, but basically if client config says not to use editor templates for invocation arg-only completion snippets, see if the snippet contains what appears to be an invocation arg list with template variables, and if so and if there are no non-end variables outside of that invocation arg list, just update the snippet to be <beforeArgList>($0)<afterArgList> with any other $0 removed. That way when the snippet is parsed into a template, it will just result in the caret being moved into the invocation arg parens.

return snippetContent;
}

@Contract("null -> false")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added @Contract annotations here and below because the IDE's nullability inspection was having trouble determining the relationships between the params and the results and was raising false inspection positives.

/**
* Common interface for language server definitions that support client configuration.
*/
public interface ClientConfigurableLanguageServerDefinition {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the part that probably warrants the most discussion, and it also exists in the upcoming PR for client-side on-type formatting. Basically we need to be able to change client config settings from unit tests, but prior to this change, only UserDefinedLanguageServerDefinition has had access to client configuration. However, we need MockLanguageServerDefinition to have client configuration as well, and ideally without having to push that notion up into LanguageServerDefinition. As a result, I added this common interface for LanguageServerDefinition implementations that are client-configurable, and I implemented that interface on both UserDefinedLanguageServerDefinition and MockLanguageServerDefinition. There will obviously be a few other related changes below, but hopefully that sets the context. And I'm certainly open to other options for how client config could be exposed in unit tests, but this certainly works nicely and without any major ripple-effect.

@@ -24,8 +25,15 @@ public class UserDefinedCompletionFeature extends LSPCompletionFeature {

@Override
public boolean useContextAwareSorting(@NotNull PsiFile file) {
UserDefinedLanguageServerDefinition serverDefinition = (UserDefinedLanguageServerDefinition) getClientFeatures().getServerDefinition();
ClientConfigurableLanguageServerDefinition serverDefinition = (ClientConfigurableLanguageServerDefinition) getClientFeatures().getServerDefinition();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Previously it was deemed safe to blindly cast to UserDefinedLanguageServerDefinition so I'm doing the same here with ClientConfigurableLanguageServerDefinition. However, I would feel better checking the type before casting with this change. Thoughts? If you agree, I'll update all such usages to be more defensive.

@@ -34,7 +35,7 @@
* {@link com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinition} implementation to start a
* language server with a process command defined by the user.
*/
public class UserDefinedLanguageServerDefinition extends LanguageServerDefinition {
public class UserDefinedLanguageServerDefinition extends LanguageServerDefinition implements ClientConfigurableLanguageServerDefinition {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here's where it has been updated to implement the client-configurable interface.

@@ -1,7 +1,8 @@
{
"caseSensitive": true,
"completion": {
"useContextAwareSorting": true
"useContextAwareSorting": true,
"useTemplateForInvocationOnlySnippet": false
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated all of the templates to disable editor templates for invocation-only snippets similar to how I enabled context-aware sorting in the templates.

/**
* Tests code completion client configuration settings using a mock CSS language server.
*/
public class CssCompletionClientConfigTest extends LSPCompletionClientConfigFixtureTestCase {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The CSS test exercises whitespace-delimited argument list snippets as well as some complex snippets with variable values that are enumerated lists/choices.

/**
* Tests code completion client configuration settings using a mock TypeScript language server.
*/
public class TypeScriptCompletionClientConfigTest extends LSPCompletionClientConfigFixtureTestCase {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hopefully this test will be straightforward, but it verifies the following scenarios:

  • Single invocation variable with default settings => template-inserted arg and caret after the invocation parens
  • Single invocation variable with editor templates disabled => caret inside the invocation parens
  • Single valueless invocation variable with default settings => caret inside the invocation parens
  • Single valueless invocation variable with editor templates disabled => caret inside the invocation parens
  • Multiple invocation variables with default settings => template-inserted args and caret after the invocation parens
  • Multiple invocation variables with editor templates disabled => caret inside the invocation parens
  • No invocation variables with default settings => Caret after the invocation parens
  • No invocation variables with editor templates disabled => Caret after the invocation parens
  • Multiple invocation variables and one non-invocation variable with default settings => template-inserted args and caret after the snippet
  • Multiple invocation variables and one non-invocation variable with editor templates disabled => template-inserted args and caret after the snippet

Note that in a unit test context, the template is evaluated as if the user had tabbed through all tab stops, so for those where a template is expected, I'm just confirming that the expected arg defaults were inserted in the appropriate places and the caret was moved to the template's designated end offset.

CompletionList mockCompletionList = JSONUtils.getLsp4jGson().fromJson(mockTextCompletionJson, CompletionList.class);
MockLanguageServer.INSTANCE.setCompletionList(mockCompletionList);

CompletionItem mockCompletionItem = JSONUtils.getLsp4jGson().fromJson(mockCompletionItemResolveJson, CompletionItem.class);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I had to add the ability to specify the mock resolved completion item.

}

// Configure the language server's completion client configuration
LanguageServerItem languageServer = ContainerUtil.getFirstItem(languageServers);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now we need to get the language server that's going to be used and set it up properly for client config testing.


// Use a configurable completion feature
LSPClientFeatures clientFeatures = languageServer.getClientFeatures();
clientFeatures.setCompletionFeature(new UserDefinedCompletionFeature());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Make sure that the mock server is using a configurable completion feature.

clientFeatures.setCompletionFeature(new UserDefinedCompletionFeature());

// Enable completion item resolution
languageServer.getServerCapabilities().setCompletionProvider(new CompletionOptions(true, null));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

By default the mock completion provider's options don't enable resolution, so must enable that for this test.

languageServer.getServerCapabilities().setCompletionProvider(new CompletionOptions(true, null));

// Update client configuration as required for this test scenario
if (clientConfigCustomizer != null) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

And finally, get the client configuration from the server and apply any config changes for this test scenario.

clientConfigCustomizer.accept(clientConfiguration);
}

// Move to the offset at which completion should be triggered
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hopefully the rest is pretty self-explanatory.

@@ -162,6 +162,10 @@ public void setCompletionList(CompletionList completionList) {
this.textDocumentService.setMockCompletionList(completionList);
}

public void setCompletionItem(CompletionItem completionItem) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Like I said above, need to be able to set the mock resolved completion item.


/**
* {@link LanguageServerDefinition} implementation to register the mock language server.
*/
public class MockLanguageServerDefinition extends LanguageServerDefinition {
public class MockLanguageServerDefinition extends LanguageServerDefinition implements ClientConfigurableLanguageServerDefinition {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The mock language server is now client-configurable as well.


private static final String SERVER_ID = "mock-server-id";

private final ClientConfigurationSettings clientConfigurationSettings = new ClientConfigurationSettings();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

And it just starts with a default client configuration.

@@ -35,6 +35,7 @@
public class MockTextDocumentService implements TextDocumentService {

private CompletionList mockCompletionList;
private CompletionItem mockCompletionItem;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Other stuff required to be able to mock the resolved completion item.

…on-only snippet so that it happens during the transformation from snippet to template.
context.getProject(),
this::getVariableValue,
indentOptions,
useTemplateForInvocationOnlySnippet
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We now pass this state to the snippet-to-template transform.

template,
variableResolver,
indentOptions,
useTemplateForInvocationOnlySnippet
Copy link
Contributor Author

Choose a reason for hiding this comment

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

And propagate it to the template loader.


private final List<String> existingVariables;
private final List<SnippetTemplateSegment> templateSegments = new LinkedList<>();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Instead of building the template on-the-fly, we accumulate the exact same information that we'd need to build the template, and we do it in endSnippet() instead. That allows us to have full context of the resulting template segments so we can see whether/how it can be transformed/simplified.

List<SnippetTemplateSegment> effectiveTemplateSegments = templateSegments;

// If we should not use a template for an invocation-only snippet, see whether this fits the pattern
if (!useTemplateForInvocationOnlySnippet) {
Copy link
Contributor Author

@SCWells72 SCWells72 Jan 14, 2025

Choose a reason for hiding this comment

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

And here's where the actual simplification logic exists. If configured to simplify invocation-only snippets, we look at the segments that were accumulated and, if all (non-end) variables were found within what seems to be an invocation (basically balanced paired parentheses), all variables are removed from the template and it ends up becoming:

<textSegmentsBeforeAndIncludingInvocationStart><endVariable><textSegmentsAfterAndIncludingInvocationEnd>

This also guards against snippets with nested invocation parens and more than one invocation pattern.


// If we found an invocation and all variables were inside of it, use the simplified template segments
if (hasInvocation && !hasVariablesOutsideInvocation) {
effectiveTemplateSegments = simplifiedTemplateSegments;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If it looks like an invocation-only snippet, use the simplified segments below when building the actual template.

…vocations and checking for proper balancing before using the simplified template segments.
}

@Override
public void text(String text) {
template.addTextSegment(formatText(text));
templateSegments.add(SnippetTemplateSegment.textSegment(formatText(text)));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

From here down, what you'll see is the change from building the template itself to accumulating what's needed to build the template in endSnippet().

* This is a simple wrapper for a {@link com.intellij.codeInsight.template.Template} segment that allows deferred
* creation of the corresponding template with all segment information retained.
*/
class SnippetTemplateSegment {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

And this is the way we accumulate that state. I've basically added factory methods for each of the types of template segment that are created above and simple ways to check which type of segment each is so it can be used safely/properly when building the final template.

/**
* Tests behavior of {@link SnippetTemplateLoader} via {@link SnippetTemplateFactory}.
*/
public class SnippetTemplateLoaderTest extends LSPCompletionFixtureTestCase {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Direct tests for SnippetTemplateLoader to exercise common and corner-case scenarios.

@angelozerr angelozerr self-requested a review January 15, 2025 10:58
@angelozerr angelozerr merged commit 23de6e8 into redhat-developer:main Jan 15, 2025
6 checks passed
@angelozerr
Copy link
Contributor

It works like a charm, great job @SCWells72 !

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