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

vscode: add "wrap in element" code action #3420

Merged
merged 5 commits into from
Sep 7, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
14 changes: 14 additions & 0 deletions editors/vscode/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as vscode from "vscode";
import { PropertiesViewProvider } from "./properties_webview";
import * as wasm_preview from "./wasm_preview";
import * as lsp_commands from "../../../tools/slintpad/src/shared/lsp_commands";
import * as snippets from "./snippets";

import {
BaseLanguageClient,
Expand Down Expand Up @@ -110,6 +111,19 @@ export function languageClientOptions(
}
return next(command, args);
},
async provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range,
context: vscode.CodeActionContext,
token: vscode.CancellationToken,
next: any,
) {
const actions = await next(document, range, context, token);
if (actions) {
snippets.detectSnippetCodeActions(actions);
}
return actions;
},
},
};
}
Expand Down
3 changes: 3 additions & 0 deletions editors/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as vscode from "vscode";
import { PropertiesViewProvider } from "./properties_webview";
import * as wasm_preview from "./wasm_preview";
import * as common from "./common";
import * as snippets from "./snippets";

import {
LanguageClient,
Expand Down Expand Up @@ -168,6 +169,8 @@ function startClient(context: vscode.ExtensionContext) {
clientOptions,
);

cl.registerFeature(new snippets.SnippetTextEditFeature());
jpnurmi marked this conversation as resolved.
Show resolved Hide resolved

cl.onDidChangeState((event) => {
let properly_stopped = cl.hasOwnProperty("slint_stopped");
if (
Expand Down
73 changes: 73 additions & 0 deletions editors/vscode/src/snippets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright © Danny Tuppeny <danny@tuppeny.com>
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT

// LSP code actions and workspace edits do not yet natively support snippets,
// or allow specifying the cursor position:
// https://github.com/microsoft/language-server-protocol/issues/724
//
// This file implements an experimental SnippetTextEdit feature inspired by
// [rust-analyzer](https://github.com/rust-lang/rust-analyzer/blob/master/docs/dev/lsp-extensions.md#snippet-textedit)
// and [Dart-Code](https://github.com/Dart-Code/Dart-Code/blob/master/src/extension/analysis/analyzer_lsp_snippet_text_edits.ts).

// cSpell: ignore Tuppeny

import * as vscode from "vscode";
import { ClientCapabilities, StaticFeature } from "vscode-languageclient";

export class SnippetTextEditFeature implements StaticFeature {
private command: vscode.Disposable | undefined;

fillClientCapabilities(capabilities: ClientCapabilities) {
capabilities.experimental = capabilities.experimental ?? {};
Object.assign(capabilities.experimental, { snippetTextEdit: true });
}

initialize() {
this.command = vscode.commands.registerCommand(
"_slint.applySnippetTextEdit",
this.applySnippetTextEdit,
);
}

dispose() {
this.command?.dispose();
}

private async applySnippetTextEdit(uri: vscode.Uri, edit: vscode.TextEdit) {
// Compensate for VS Code's automatic indentation
const doc = await vscode.workspace.openTextDocument(uri);
const line = doc.lineAt(edit.range.start.line);
const indent = " ".repeat(line.firstNonWhitespaceCharacterIndex);
const newText = edit.newText.replaceAll(`\n${indent}`, "\n");

const editor = await vscode.window.showTextDocument(doc);
await editor.insertSnippet(
new vscode.SnippetString(newText),
edit.range,
);
}
}

export function detectSnippetCodeActions(
actions: Array<vscode.Command | vscode.CodeAction>,
) {
for (const action of actions) {
if (action instanceof vscode.CodeAction && action.edit) {
const edits = action.edit.entries();
if (edits.length === 1 && edits[0][1].length === 1) {
const uri = edits[0][0];
const textEdit = edits[0][1][0];
// Check for "$0" or "${0:foo}" snippet placeholders
if (/\$(?:0|\{0:(?:[^}]*)\})/.test(textEdit.newText)) {
action.edit = undefined;
action.command = {
title: "Apply snippet text edit",
command: "_slint.applySnippetTextEdit",
arguments: [uri, textEdit],
};
}
}
}
}
}
132 changes: 130 additions & 2 deletions tools/lsp/language.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod semantic_tokens;
mod test;

use crate::common::{PreviewApi, Result};
use crate::language::properties::find_element_indent;
use crate::util::{map_node, map_range, map_token, to_lsp_diag};

#[cfg(target_arch = "wasm32")]
Expand Down Expand Up @@ -253,7 +254,9 @@ pub fn register_request_handlers(rh: &mut RequestHandler) {
let document_cache = &mut ctx.document_cache.borrow_mut();

let result = token_descr(document_cache, &params.text_document.uri, &params.range.start)
.and_then(|(token, _)| get_code_actions(document_cache, token));
.and_then(|(token, _)| {
get_code_actions(document_cache, token, &ctx.init_param.capabilities)
});
Ok(result)
});
rh.register::<ExecuteCommand, _>(|params, ctx| async move {
Expand Down Expand Up @@ -781,9 +784,18 @@ pub fn token_at_offset(doc: &syntax_nodes::Document, offset: u32) -> Option<Synt
Some(SyntaxToken { token, source_file: doc.source_file.clone() })
}

fn has_experimental_client_capability(capabilities: &ClientCapabilities, name: &str) -> bool {
capabilities
.experimental
.as_ref()
.and_then(|o| o.get(name).and_then(|v| v.as_bool()))
.unwrap_or(false)
}

fn get_code_actions(
_document_cache: &mut DocumentCache,
document_cache: &mut DocumentCache,
token: SyntaxToken,
client_capabilities: &ClientCapabilities,
) -> Option<Vec<CodeActionOrCommand>> {
let node = token.parent();
let uri = Url::from_file_path(token.source_file.path()).ok()?;
Expand Down Expand Up @@ -830,6 +842,39 @@ fn get_code_actions(
}),
..Default::default()
}));
} else if token.kind() == SyntaxKind::Identifier
&& node.kind() == SyntaxKind::QualifiedName
&& node.parent().map(|n| n.kind()) == Some(SyntaxKind::Element)
&& has_experimental_client_capability(client_capabilities, "snippetTextEdit")
{
let r = map_range(&token.source_file, node.parent().unwrap().text_range());
let element = element_at_position(document_cache, &uri, &r.start);
let element_indent = element.as_ref().and_then(find_element_indent);
let indented_lines = node
.parent()
.unwrap()
.text()
.to_string()
.lines()
.map(|line| format!(" {}", line))
.collect::<Vec<String>>();
let edits = vec![TextEdit::new(
lsp_types::Range::new(r.start, r.end),
format!(
"${{0:element}} {{\n{}{}\n}}",
element_indent.unwrap_or("".into()),
indented_lines.join("\n")
),
)];
result.push(CodeActionOrCommand::CodeAction(lsp_types::CodeAction {
title: "Wrap in element...".into(),
jpnurmi marked this conversation as resolved.
Show resolved Hide resolved
kind: Some(lsp_types::CodeActionKind::REFACTOR),
edit: Some(WorkspaceEdit {
changes: Some(std::iter::once((uri, edits)).collect()),
..Default::default()
}),
..Default::default()
}));
}

(!result.is_empty()).then_some(result)
Expand Down Expand Up @@ -1306,4 +1351,87 @@ component Demo {
unreachable!();
}
}

#[test]
fn test_code_actions() {
let (mut dc, url, _) = complex_document_cache();
let mut capabilities = ClientCapabilities::default();

let text_literal = lsp_types::Range::new(Position::new(33, 22), Position::new(33, 33));
assert_eq!(
token_descr(&mut dc, &url, &text_literal.start)
.and_then(|(token, _)| get_code_actions(&mut dc, token, &capabilities)),
Some(vec![CodeActionOrCommand::CodeAction(lsp_types::CodeAction {
title: "Wrap in `@tr()`".into(),
edit: Some(WorkspaceEdit {
changes: Some(
std::iter::once((
url.clone(),
vec![
TextEdit::new(
lsp_types::Range::new(text_literal.start, text_literal.start),
"@tr(".into()
),
TextEdit::new(
lsp_types::Range::new(text_literal.end, text_literal.end),
")".into()
)
]
))
.collect()
),
..Default::default()
}),
..Default::default()
}),])
);

let text_element = lsp_types::Range::new(Position::new(32, 12), Position::new(35, 13));
for offset in 0..=4 {
let pos = Position::new(text_element.start.line, text_element.start.character + offset);

capabilities.experimental = None;
assert_eq!(
token_descr(&mut dc, &url, &pos).and_then(|(token, _)| get_code_actions(
&mut dc,
token,
&capabilities
)),
None
);

capabilities.experimental = Some(serde_json::json!({"snippetTextEdit": true}));
assert_eq!(
token_descr(&mut dc, &url, &pos).and_then(|(token, _)| get_code_actions(
&mut dc,
token,
&capabilities
)),
Some(vec![CodeActionOrCommand::CodeAction(lsp_types::CodeAction {
title: "Wrap in element...".into(),
kind: Some(lsp_types::CodeActionKind::REFACTOR),
edit: Some(WorkspaceEdit {
changes: Some(
std::iter::once((
url.clone(),
vec![TextEdit::new(
text_element,
r#"${0:element} {
Text {
text: "Duration:";
vertical-alignment: center;
}
}"#
.into()
)]
))
.collect()
),
..Default::default()
}),
..Default::default()
}),])
);
}
}
}
2 changes: 1 addition & 1 deletion tools/lsp/language/properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ fn set_binding_on_known_property(

// Find the indentation of the element itself as well as the indentation of properties inside the
// element. Returns the element indent followed by the block indent
fn find_element_indent(element: &ElementRc) -> Option<String> {
pub fn find_element_indent(element: &ElementRc) -> Option<String> {
Copy link
Member

Choose a reason for hiding this comment

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

This should probably get moved to a more discoverable place, now that it got promoted to a generic utility function.

That can be a separate patch and does not need to be done in here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sounds good. The comment looks outdated too. I can follow up with a separate PR.

let mut token =
element.borrow().node.as_ref().and_then(|n| n.first_token()).and_then(|t| t.prev_token());
while let Some(t) = token {
Expand Down
1 change: 1 addition & 0 deletions xtask/src/license_headers_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ lazy_static! {
("^api/cpp/docs/conf\\.py$", LicenseLocation::NoLicense),
("^docs/reference/Pipfile$", LicenseLocation::NoLicense),
("^docs/reference/conf\\.py$", LicenseLocation::NoLicense),
("^editors/vscode/src/snippets\\.ts$", LicenseLocation::NoLicense), // liberal license
("^editors/tree-sitter-slint/binding\\.gyp$", LicenseLocation::NoLicense), // liberal license
("^editors/tree-sitter-slint/test-to-corpus\\.py$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
("^Cargo\\.lock$", LicenseLocation::NoLicense),
Expand Down