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

Add code action to do string interpolation #4274

Merged
merged 6 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,31 @@
}
```

([Surya Rose](https://github.com/GearsDatapacks))

- The language server now suggests a code action to easily interpolate a value
into a string. If the cursor is inside a literal string the language server
will offer to split it:

```gleam
"wibble | wobble"
// ^ Triggering the action with the cursor
// here will produce this:
"wibble " <> todo <> " wobble"
```

And if the cursor is selecting a valid gleam name, the language server will
offer to interpolate it as a variable:

```gleam
"wibble wobble woo"
// ^^^^^^ Triggering the code action if you're
// selecting an entire name, will produce this:
"wibble " <> wobble <> " woo"
```

([Giacomo Cavalieri](https://github.com/giacomocavalieri))

### Formatter

### Bug fixes
Expand Down
11 changes: 11 additions & 0 deletions compiler-core/src/language_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ pub fn src_span_to_lsp_range(location: SrcSpan, line_numbers: &LineNumbers) -> R
)
}

pub fn lsp_range_to_src_span(range: Range, line_numbers: &LineNumbers) -> SrcSpan {
let Range { start, end } = range;
let start = line_numbers.byte_index(start.line, start.character);
let end = line_numbers.byte_index(end.line, end.character);
SrcSpan { start, end }
}

/// A little wrapper around LineNumbers to make it easier to build text edits.
///
#[derive(Debug)]
Expand All @@ -70,6 +77,10 @@ impl<'a> TextEdits<'a> {
src_span_to_lsp_range(location, self.line_numbers)
}

pub fn lsp_range_to_src_span(&self, range: Range) -> SrcSpan {
lsp_range_to_src_span(range, self.line_numbers)
}

pub fn replace(&mut self, location: SrcSpan, new_text: String) {
self.edits.push(TextEdit {
range: src_span_to_lsp_range(location, self.line_numbers),
Expand Down
137 changes: 137 additions & 0 deletions compiler-core/src/language_server/code_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4906,3 +4906,140 @@ impl<'ast> ast::visit::Visit<'ast> for ConvertToPipe<'ast> {
ast::visit::visit_typed_pipeline_assignment(self, first_value);
}
}

/// Code action to split/interpolate a value into a string. If the cursor is
/// inside the string (not selecting anything) the language server will offer to
/// split it:
///
/// ```gleam
/// "wibble | wobble"
/// // ^ [Split string]
/// // Will produce the following
/// "wibble " <> todo <> " wobble"
/// ```
///
/// If the cursor is selecting an entire valid gleam name, then the language
/// server will offer to interpolate it as a variable:
///
/// ```gleam
/// "wibble wobble woo"
/// // ^^^^^^ [Interpolate variable]
/// // Will produce the following
/// "wibble " <> wobble <> " woo"
/// ```
///
/// > Note: the cursor won't end up right after the inserted variable/todo.
/// > that's a bit annoying, but in a future LSP version we will be able to
/// > isnert tab stops to allow one to jump to the newly added variable/todo.
///
pub struct InterpolateString<'a> {
module: &'a Module,
params: &'a CodeActionParams,
edits: TextEdits<'a>,
string_interpolation: Option<(SrcSpan, StringInterpolation)>,
}

#[derive(Clone, Copy)]
enum StringInterpolation {
InterpolateValue { value_location: SrcSpan },
SplitString { split_at: u32 },
}

impl<'a> InterpolateString<'a> {
pub fn new(
module: &'a Module,
line_numbers: &'a LineNumbers,
params: &'a CodeActionParams,
) -> Self {
Self {
module,
params,
edits: TextEdits::new(line_numbers),
string_interpolation: None,
}
}

pub fn code_actions(mut self) -> Vec<CodeAction> {
self.visit_typed_module(&self.module.ast);

let Some((_, interpolation)) = self.string_interpolation else {
return vec![];
};

let title = match interpolation {
StringInterpolation::InterpolateValue { value_location } => {
let name = self
.module
.code
.get(value_location.start as usize..value_location.end as usize)
.expect("invalid value range");

if is_valid_lowercase_name(name) {
self.edits
.insert(value_location.start, format!("\" <> {name} <> \""));
self.edits.delete(value_location);
"Interpolate variable"
} else if self.can_split_string_at(value_location.end) {
// If the string is not a valid name we just try and split
// the string at the end of the selection.
self.edits
.insert(value_location.end, "\" <> todo <> \"".into());
"Split string"
} else {
// Otherwise there's no meaningful action we can do.
return vec![];
}
}

StringInterpolation::SplitString { split_at } if self.can_split_string_at(split_at) => {
self.edits.insert(split_at, "\" <> todo <> \"".into());
"Split string"
}

StringInterpolation::SplitString { .. } => return vec![],
};

let mut action = Vec::with_capacity(1);
CodeActionBuilder::new(title)
.kind(CodeActionKind::REFACTOR_REWRITE)
.changes(self.params.text_document.uri.clone(), self.edits.edits)
.preferred(false)
.push_to(&mut action);
action
}

fn can_split_string_at(&self, at: u32) -> bool {
self.string_interpolation
.is_some_and(|(string_location, _)| {
!(at <= string_location.start + 1 || at >= string_location.end - 1)
})
}
}

impl<'ast> ast::visit::Visit<'ast> for InterpolateString<'ast> {
fn visit_typed_expr_string(
&mut self,
location: &'ast SrcSpan,
_type_: &'ast Arc<Type>,
_value: &'ast EcoString,
) {
// We can only interpolate/split a string if the cursor is somewhere
// within its location, otherwise we skip it.
let string_range = self.edits.src_span_to_lsp_range(*location);
if !within(self.params.range, string_range) {
return;
}

let selection @ SrcSpan { start, end } =
self.edits.lsp_range_to_src_span(self.params.range);

let interpolation = if start == end {
StringInterpolation::SplitString { split_at: start }
} else {
StringInterpolation::InterpolateValue {
value_location: selection,
}
};
self.string_interpolation = Some((*location, interpolation));
}
}
5 changes: 3 additions & 2 deletions compiler-core/src/language_server/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ use super::{
code_action_inexhaustive_let_to_case, AddAnnotations, CodeActionBuilder, ConvertFromUse,
ConvertToFunctionCall, ConvertToPipe, ConvertToUse, ExpandFunctionCapture, ExtractVariable,
FillInMissingLabelledArgs, GenerateDynamicDecoder, GenerateFunction, GenerateJsonEncoder,
InlineVariable, LetAssertToCase, PatternMatchOnValue, RedundantTupleInCaseSubject,
UseLabelShorthandSyntax,
InlineVariable, InterpolateString, LetAssertToCase, PatternMatchOnValue,
RedundantTupleInCaseSubject, UseLabelShorthandSyntax,
},
completer::Completer,
rename::{rename_local_variable, VariableRenameKind},
Expand Down Expand Up @@ -370,6 +370,7 @@ where
actions.extend(ConvertFromUse::new(module, &lines, &params).code_actions());
actions.extend(ConvertToUse::new(module, &lines, &params).code_actions());
actions.extend(ExpandFunctionCapture::new(module, &lines, &params).code_actions());
actions.extend(InterpolateString::new(module, &lines, &params).code_actions());
actions.extend(ExtractVariable::new(module, &lines, &params).code_actions());
actions.extend(GenerateFunction::new(module, &lines, &params).code_actions());
actions.extend(ConvertToPipe::new(module, &lines, &params).code_actions());
Expand Down
79 changes: 79 additions & 0 deletions compiler-core/src/language_server/tests/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ const GENERATE_FUNCTION: &str = "Generate function";
const CONVERT_TO_FUNCTION_CALL: &str = "Convert to function call";
const INLINE_VARIABLE: &str = "Inline variable";
const CONVERT_TO_PIPE: &str = "Convert to pipe";
const SPLIT_STRING: &str = "Split string";
const INTERPOLATE_VARIABLE: &str = "Interpolate variable";

macro_rules! assert_code_action {
($title:expr, $code:literal, $range:expr $(,)?) => {
Expand Down Expand Up @@ -114,6 +116,83 @@ macro_rules! assert_no_code_actions {
};
}

#[test]
fn split_string() {
assert_code_action!(
SPLIT_STRING,
r#"pub fn main() {
"wibble wobble woo"
}"#,
find_position_of("wobble").to_selection()
);
}

#[test]
fn no_split_string_right_at_the_start() {
assert_no_code_actions!(
SPLIT_STRING,
r#"pub fn main() {
"wibble wobble woo"
}"#,
find_position_of("wibble").to_selection()
);
}

#[test]
fn no_split_string_right_at_the_end() {
assert_no_code_actions!(
SPLIT_STRING,
r#"pub fn main() {
"wibble wobble woo"
}"#,
find_position_of("\"").nth_occurrence(2).to_selection()
);
}

#[test]
fn no_split_string_before_the_start() {
assert_no_code_actions!(
SPLIT_STRING,
r#"pub fn main() {
"wibble wobble woo"
}"#,
find_position_of("\"").to_selection()
);
}

#[test]
fn no_split_string_after_the_end() {
assert_no_code_actions!(
SPLIT_STRING,
r#"pub fn main() {
"wibble wobble woo"//we need this comment so we can put the cursor _after_ the closing quote
}"#,
find_position_of("\"/").under_last_char().to_selection()
);
}

#[test]
fn interpolate_variable_inside_string() {
assert_code_action!(
INTERPOLATE_VARIABLE,
r#"pub fn main() {
"wibble wobble woo"
}"#,
find_position_of("wobble").select_until(find_position_of("wobble ").under_last_char()),
);
}

#[test]
fn fallback_to_split_string_when_selecting_invalid_name() {
assert_code_action!(
SPLIT_STRING,
r#"pub fn main() {
"wibble wobble woo woo"
}"#,
find_position_of("wobble").select_until(find_position_of("woo ").under_last_char()),
);
}

#[test]
fn test_remove_unused_simple() {
let src = "
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
source: compiler-core/src/language_server/tests/action.rs
expression: "pub fn main() {\n \"wibble wobble woo woo\"\n}"
---
----- BEFORE ACTION
pub fn main() {
"wibble wobble woo woo"
▔▔▔▔▔▔▔▔▔▔↑
}


----- AFTER ACTION
pub fn main() {
"wibble wobble woo" <> todo <> " woo"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
source: compiler-core/src/language_server/tests/action.rs
expression: "pub fn main() {\n \"wibble wobble woo\"\n}"
---
----- BEFORE ACTION
pub fn main() {
"wibble wobble woo"
▔▔▔▔▔▔↑
}


----- AFTER ACTION
pub fn main() {
"wibble " <> wobble <> " woo"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
source: compiler-core/src/language_server/tests/action.rs
expression: "pub fn main() {\n \"wibble wobble woo\"\n}"
---
----- BEFORE ACTION
pub fn main() {
"wibble wobble woo"
}


----- AFTER ACTION
pub fn main() {
"wibble " <> todo <> "wobble woo"
}
2 changes: 1 addition & 1 deletion compiler-core/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ where
}
if tail.is_some()
&& elements.is_empty()
&& elements_after_tail.as_ref().map_or(true, |e| e.is_empty())
&& elements_after_tail.as_ref().is_none_or(|e| e.is_empty())
{
return parse_error(
ParseErrorType::ListSpreadWithoutElements,
Expand Down
Loading