Skip to content

Commit 7aadda4

Browse files
authored
Add infrastructure for Code Actions đź’ˇ, and our first action for generating a roxygen template (#809)
* Add `point_and_byte_from_cursor()` utility * Add `is_binary_operator_of_kind()` utility * Extract out `convert_lsp_range_to_tree_sitter_range()` utility * Add code action infrastructure, and "Generate a roxygen template" code action * Reference the light bulb blog post * Rename `byte` to `offset` * Use `lsp_types::` prefix * Don't provide the code action for local functions * Use `lsp_types::` in `roxygen.rs` too
1 parent e7e9ed1 commit 7aadda4

20 files changed

+867
-29
lines changed

‎crates/ark/src/fixtures/utils.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,19 @@ pub(crate) fn r_test_init() {
3737
}
3838

3939
pub fn point_from_cursor(x: &str) -> (String, Point) {
40+
let (text, point, _offset) = point_and_offset_from_cursor(x);
41+
(text, point)
42+
}
43+
44+
pub fn point_and_offset_from_cursor(x: &str) -> (String, Point, usize) {
4045
let lines = x.split("\n").collect::<Vec<&str>>();
4146

4247
// i.e. looking for `@` in something like `fn(x = @1, y = 2)`, and it treats the
4348
// `@` as the cursor position
4449
let cursor = b'@';
4550

51+
let mut offset = 0;
52+
4653
for (line_row, line) in lines.into_iter().enumerate() {
4754
for (char_column, char) in line.as_bytes().into_iter().enumerate() {
4855
if char == &cursor {
@@ -51,9 +58,12 @@ pub fn point_from_cursor(x: &str) -> (String, Point) {
5158
row: line_row,
5259
column: char_column,
5360
};
54-
return (x, point);
61+
offset += char_column;
62+
return (x, point, offset);
5563
}
5664
}
65+
// `+ 1` for the removed `\n` at the end of this line
66+
offset += line.as_bytes().len() + 1;
5767
}
5868

5969
panic!("`x` must include a `@` character!");
@@ -107,14 +117,15 @@ pub fn package_is_installed(package: &str) -> bool {
107117
mod tests {
108118
use tree_sitter::Point;
109119

110-
use crate::fixtures::point_from_cursor;
120+
use crate::fixtures::point_and_offset_from_cursor;
111121

112122
#[test]
113123
#[rustfmt::skip]
114-
fn test_point_from_cursor() {
115-
let (text, point) = point_from_cursor("1@ + 2");
124+
fn test_point_and_offset_from_cursor() {
125+
let (text, point, offset) = point_and_offset_from_cursor("1@ + 2");
116126
assert_eq!(text, "1 + 2".to_string());
117127
assert_eq!(point, Point::new(0, 1));
128+
assert_eq!(offset, 1);
118129

119130
let text =
120131
"fn(
@@ -124,8 +135,9 @@ mod tests {
124135
"fn(
125136
arg = 3
126137
)";
127-
let (text, point) = point_from_cursor(text);
138+
let (text, point, offset) = point_and_offset_from_cursor(text);
128139
assert_eq!(text, expect);
129140
assert_eq!(point, Point::new(1, 7));
141+
assert_eq!(offset, 11);
130142
}
131143
}

‎crates/ark/src/lsp/backend.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ pub(crate) enum LspRequest {
143143
StatementRange(StatementRangeParams),
144144
HelpTopic(HelpTopicParams),
145145
OnTypeFormatting(DocumentOnTypeFormattingParams),
146+
CodeAction(CodeActionParams),
146147
VirtualDocument(VirtualDocumentParams),
147148
InputBoundaries(InputBoundariesParams),
148149
}
@@ -164,6 +165,7 @@ pub(crate) enum LspResponse {
164165
StatementRange(Option<StatementRangeResponse>),
165166
HelpTopic(Option<HelpTopicResponse>),
166167
OnTypeFormatting(Option<Vec<TextEdit>>),
168+
CodeAction(Option<CodeActionResponse>),
167169
VirtualDocument(VirtualDocumentResponse),
168170
InputBoundaries(InputBoundariesResponse),
169171
}
@@ -371,6 +373,14 @@ impl LanguageServer for Backend {
371373
LspResponse::OnTypeFormatting
372374
)
373375
}
376+
377+
async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
378+
cast_response!(
379+
self,
380+
self.request(LspRequest::CodeAction(params)).await,
381+
LspResponse::CodeAction
382+
)
383+
}
374384
}
375385

376386
// Custom methods for the backend.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//
2+
// capabilities.rs
3+
//
4+
// Copyright (C) 2025 Posit Software, PBC. All rights reserved.
5+
//
6+
//
7+
8+
use tower_lsp::lsp_types;
9+
use tower_lsp::lsp_types::CodeActionKind;
10+
use tower_lsp::lsp_types::CodeActionOptions;
11+
use tower_lsp::lsp_types::CodeActionProviderCapability;
12+
use tower_lsp::lsp_types::WorkDoneProgressOptions;
13+
14+
/// Capabilities negotiated with [lsp_types::ClientCapabilities]
15+
#[derive(Debug)]
16+
pub(crate) struct Capabilities {
17+
dynamic_registration_for_did_change_configuration: bool,
18+
code_action_literal_support: bool,
19+
workspace_edit_document_changes: bool,
20+
}
21+
22+
impl Capabilities {
23+
pub(crate) fn new(client_capabilities: lsp_types::ClientCapabilities) -> Self {
24+
let dynamic_registration_for_did_change_configuration = client_capabilities
25+
.workspace
26+
.as_ref()
27+
.and_then(|workspace| workspace.did_change_configuration)
28+
.and_then(|did_change_configuration| did_change_configuration.dynamic_registration)
29+
.unwrap_or(false);
30+
31+
// In theory the client also tells us which code action kinds it supports inside
32+
// `code_action_literal_support`, but clients are guaranteed to ignore any they
33+
// don't support, so we just return `true` if the field exists (same as
34+
// rust-analyzer).
35+
let code_action_literal_support = client_capabilities
36+
.text_document
37+
.as_ref()
38+
.and_then(|text_document| text_document.code_action.as_ref())
39+
.and_then(|code_action| code_action.code_action_literal_support.as_ref())
40+
.map_or(false, |_| true);
41+
42+
let workspace_edit_document_changes = client_capabilities
43+
.workspace
44+
.as_ref()
45+
.and_then(|workspace| workspace.workspace_edit.as_ref())
46+
.and_then(|workspace_edit| workspace_edit.document_changes)
47+
.map_or(false, |document_changes| document_changes);
48+
49+
Self {
50+
dynamic_registration_for_did_change_configuration,
51+
code_action_literal_support,
52+
workspace_edit_document_changes,
53+
}
54+
}
55+
56+
pub(crate) fn dynamic_registration_for_did_change_configuration(&self) -> bool {
57+
self.dynamic_registration_for_did_change_configuration
58+
}
59+
60+
pub(crate) fn code_action_literal_support(&self) -> bool {
61+
self.code_action_literal_support
62+
}
63+
64+
// Currently only used for testing
65+
#[cfg(test)]
66+
pub(crate) fn with_code_action_literal_support(
67+
mut self,
68+
code_action_literal_support: bool,
69+
) -> Self {
70+
self.code_action_literal_support = code_action_literal_support;
71+
return self;
72+
}
73+
74+
pub(crate) fn workspace_edit_document_changes(&self) -> bool {
75+
self.workspace_edit_document_changes
76+
}
77+
78+
// Currently only used for testing
79+
#[cfg(test)]
80+
pub(crate) fn with_workspace_edit_document_changes(
81+
mut self,
82+
workspace_edit_document_changes: bool,
83+
) -> Self {
84+
self.workspace_edit_document_changes = workspace_edit_document_changes;
85+
return self;
86+
}
87+
88+
pub(crate) fn code_action_provider_capability(&self) -> Option<CodeActionProviderCapability> {
89+
if !self.code_action_literal_support() {
90+
return None;
91+
}
92+
93+
// Currently we only support documentation generating code actions, which don't
94+
// map to an existing kind. rust-analyzer maps them to `EMPTY`, so we follow suit.
95+
// Currently no code actions require delayed resolution.
96+
Some(CodeActionProviderCapability::Options(CodeActionOptions {
97+
code_action_kinds: Some(vec![CodeActionKind::EMPTY]),
98+
work_done_progress_options: WorkDoneProgressOptions::default(),
99+
resolve_provider: Some(false),
100+
}))
101+
}
102+
}
103+
104+
// This is unfortunately required right now, because `LspState` is initialized before we
105+
// get the `Initialize` LSP request. We immediately overwrite the `LspState`
106+
// `capabilities` field after receiving the `Initialize` request.
107+
impl Default for Capabilities {
108+
fn default() -> Self {
109+
Self {
110+
dynamic_registration_for_did_change_configuration: false,
111+
code_action_literal_support: false,
112+
workspace_edit_document_changes: false,
113+
}
114+
}
115+
}

‎crates/ark/src/lsp/code_action.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//! Code actions
2+
//!
3+
//! These are contextual light bulbs that appear when the user's cursor is at a particular
4+
//! position. They allow for small context specific quick fixes, refactors, documentation
5+
//! generation, and other small code adjustments.
6+
//!
7+
//! Modeled after rust-analyzer's blog post:
8+
//! https://rust-analyzer.github.io/blog/2020/09/28/how-to-make-a-light-bulb.html
9+
10+
use std::collections::HashMap;
11+
12+
use tower_lsp::lsp_types;
13+
use tree_sitter::Range;
14+
use url::Url;
15+
16+
use crate::lsp::capabilities::Capabilities;
17+
use crate::lsp::code_action::roxygen::roxygen_documentation;
18+
use crate::lsp::documents::Document;
19+
20+
mod roxygen;
21+
22+
/// A small wrapper around [CodeActionResponse] that make a few things more ergonomic
23+
pub(crate) struct CodeActions {
24+
response: lsp_types::CodeActionResponse,
25+
}
26+
27+
pub(crate) fn code_actions(
28+
uri: &Url,
29+
document: &Document,
30+
range: Range,
31+
capabilities: &Capabilities,
32+
) -> lsp_types::CodeActionResponse {
33+
let mut actions = CodeActions::new();
34+
35+
roxygen_documentation(&mut actions, uri, document, range, capabilities);
36+
37+
actions.into_response()
38+
}
39+
40+
pub(crate) fn code_action(
41+
title: String,
42+
kind: lsp_types::CodeActionKind,
43+
edit: lsp_types::WorkspaceEdit,
44+
) -> lsp_types::CodeAction {
45+
lsp_types::CodeAction {
46+
title,
47+
kind: Some(kind),
48+
edit: Some(edit),
49+
diagnostics: None,
50+
command: None,
51+
is_preferred: None,
52+
disabled: None,
53+
data: None,
54+
}
55+
}
56+
57+
/// Creates a common kind of `WorkspaceEdit` composed of one or more `TextEdit`s to
58+
/// apply to a single document
59+
pub(crate) fn code_action_workspace_text_edit(
60+
uri: Url,
61+
version: Option<i32>,
62+
edits: Vec<lsp_types::TextEdit>,
63+
capabilities: &Capabilities,
64+
) -> lsp_types::WorkspaceEdit {
65+
if capabilities.workspace_edit_document_changes() {
66+
// Prefer the versioned `DocumentChanges` feature
67+
let edit = lsp_types::TextDocumentEdit {
68+
text_document: lsp_types::OptionalVersionedTextDocumentIdentifier { uri, version },
69+
edits: edits
70+
.into_iter()
71+
.map(|edit| lsp_types::OneOf::Left(edit))
72+
.collect(),
73+
};
74+
75+
let document_changes = lsp_types::DocumentChanges::Edits(vec![edit]);
76+
77+
lsp_types::WorkspaceEdit {
78+
changes: None,
79+
document_changes: Some(document_changes),
80+
change_annotations: None,
81+
}
82+
} else {
83+
// Fall back to hash map of `TextEdit`s if the client doesn't support `DocumentChanges`
84+
let mut changes = HashMap::new();
85+
changes.insert(uri, edits);
86+
87+
lsp_types::WorkspaceEdit {
88+
changes: Some(changes),
89+
document_changes: None,
90+
change_annotations: None,
91+
}
92+
}
93+
}
94+
95+
impl CodeActions {
96+
pub(crate) fn new() -> Self {
97+
Self {
98+
response: lsp_types::CodeActionResponse::new(),
99+
}
100+
}
101+
102+
pub(crate) fn add_action(&mut self, x: lsp_types::CodeAction) -> Option<()> {
103+
self.response
104+
.push(lsp_types::CodeActionOrCommand::CodeAction(x));
105+
Some(())
106+
}
107+
108+
pub(crate) fn into_response(self) -> lsp_types::CodeActionResponse {
109+
self.response
110+
}
111+
}

0 commit comments

Comments
 (0)