Skip to content

Commit bb3fee4

Browse files
committed
Add extraction refactorings
1 parent 9834439 commit bb3fee4

6 files changed

Lines changed: 1527 additions & 41 deletions

File tree

‎src/database/queries.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,13 @@ impl CompilerDatabase {
283283
crate::insight::signature_help(self, offset)
284284
}
285285

286+
pub fn refactorings(
287+
&mut self,
288+
selection: Span,
289+
) -> SemanticQueryResult<Vec<crate::refactor::Refactoring>> {
290+
crate::refactor::extract_refactorings(self, selection)
291+
}
292+
286293
pub fn expression_type(&mut self, expression: ExprId) -> SemanticQueryResult<Option<TypeId>> {
287294
Ok(self
288295
.semantic_snapshot()?

‎src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ mod lexer;
2727
mod lsp;
2828
mod memory;
2929
use splitscript_syntax::parser;
30+
mod refactor;
3031
mod resolution;
3132
mod semantic;
3233
mod service;

‎src/lsp.rs‎

Lines changed: 81 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ impl LanguageServer {
7676
"documentFormattingProvider": true,
7777
"documentSymbolProvider": true,
7878
"codeActionProvider": {
79-
"codeActionKinds": ["quickfix"],
79+
"codeActionKinds": ["quickfix", "refactor.extract"],
8080
"resolveProvider": false
8181
},
8282
"completionProvider": {
@@ -276,12 +276,19 @@ impl LanguageServer {
276276
Err(response) => return response,
277277
};
278278
let uri = params.text_document.uri;
279-
if params.context.only.as_ref().is_some_and(|kinds| {
280-
!kinds.is_empty()
281-
&& !kinds
282-
.iter()
283-
.any(|kind| kind == "quickfix" || kind.starts_with("quickfix."))
284-
}) {
279+
let permits = |requested: &str| {
280+
params.context.only.as_ref().is_none_or(|kinds| {
281+
kinds.is_empty()
282+
|| kinds.iter().any(|kind| {
283+
requested == kind
284+
|| requested.starts_with(&format!("{kind}."))
285+
|| kind.starts_with(&format!("{requested}."))
286+
})
287+
})
288+
};
289+
let permits_quick_fixes = permits("quickfix");
290+
let permits_extractions = permits("refactor.extract");
291+
if !permits_quick_fixes && !permits_extractions {
285292
return response(id, json!([]));
286293
}
287294
let Some(document) = self.documents.get_mut(&uri) else {
@@ -298,38 +305,74 @@ impl LanguageServer {
298305
) else {
299306
return error_response(id, -32602, "invalid code-action range");
300307
};
301-
let diagnostics = document.database.diagnostics();
302308
let mut actions = Vec::new();
303-
for diagnostic in diagnostics
304-
.iter()
305-
.filter(|diagnostic| diagnostic.span.start <= end && start <= diagnostic.span.end)
306-
{
307-
let mut fixes = diagnostic.fixes.clone();
308-
if fixes.is_empty()
309-
&& matches!(
310-
diagnostic.code,
311-
DiagnosticCode::UnusedDeclaration | DiagnosticCode::UnusedMember
312-
)
313-
&& let Ok(Some(plan)) = document
314-
.database
315-
.underscore_suppression_at(diagnostic.span.start)
309+
if permits_quick_fixes {
310+
let diagnostics = document.database.diagnostics();
311+
for diagnostic in diagnostics
312+
.iter()
313+
.filter(|diagnostic| diagnostic.span.start <= end && start <= diagnostic.span.end)
316314
{
317-
let original = &source[diagnostic.span.start..diagnostic.span.end];
318-
fixes.push(DiagnosticFix {
319-
title: format!("rename `{original}` to `{}`", plan.replacement),
320-
applicability: FixApplicability::MachineApplicable,
321-
edits: plan
322-
.spans
323-
.into_iter()
324-
.map(|span| TextEdit {
325-
span,
326-
replacement: plan.replacement.clone(),
315+
let mut fixes = diagnostic.fixes.clone();
316+
if fixes.is_empty()
317+
&& matches!(
318+
diagnostic.code,
319+
DiagnosticCode::UnusedDeclaration | DiagnosticCode::UnusedMember
320+
)
321+
&& let Ok(Some(plan)) = document
322+
.database
323+
.underscore_suppression_at(diagnostic.span.start)
324+
{
325+
let original = &source[diagnostic.span.start..diagnostic.span.end];
326+
fixes.push(DiagnosticFix {
327+
title: format!("rename `{original}` to `{}`", plan.replacement),
328+
applicability: FixApplicability::MachineApplicable,
329+
edits: plan
330+
.spans
331+
.into_iter()
332+
.map(|span| TextEdit {
333+
span,
334+
replacement: plan.replacement.clone(),
335+
})
336+
.collect(),
337+
});
338+
}
339+
for fix in &fixes {
340+
let edits = fix
341+
.edits
342+
.iter()
343+
.map(|edit| {
344+
json!({
345+
"range": {
346+
"start": position(&source, edit.span.start),
347+
"end": position(&source, edit.span.end)
348+
},
349+
"newText": edit.replacement
350+
})
327351
})
328-
.collect(),
329-
});
352+
.collect::<Vec<_>>();
353+
let mut changes = serde_json::Map::new();
354+
changes.insert(uri.clone(), Value::Array(edits));
355+
actions.push(json!({
356+
"title": fix.title,
357+
"kind": "quickfix",
358+
"diagnostics": [diagnostic_json(&uri, &source, diagnostic)],
359+
"isPreferred": fix.applicability == FixApplicability::MachineApplicable,
360+
"edit": { "changes": changes },
361+
"data": { "applicability": fix.applicability.to_string() }
362+
}));
363+
}
330364
}
331-
for fix in &fixes {
332-
let edits = fix
365+
}
366+
if permits_extractions
367+
&& let Ok(refactorings) = document
368+
.database
369+
.refactorings(crate::ast::Span { start, end })
370+
{
371+
for refactoring in refactorings
372+
.into_iter()
373+
.filter(|refactoring| permits(refactoring.kind.lsp_kind()))
374+
{
375+
let edits = refactoring
333376
.edits
334377
.iter()
335378
.map(|edit| {
@@ -345,12 +388,9 @@ impl LanguageServer {
345388
let mut changes = serde_json::Map::new();
346389
changes.insert(uri.clone(), Value::Array(edits));
347390
actions.push(json!({
348-
"title": fix.title,
349-
"kind": "quickfix",
350-
"diagnostics": [diagnostic_json(&uri, &source, diagnostic)],
351-
"isPreferred": fix.applicability == FixApplicability::MachineApplicable,
352-
"edit": { "changes": changes },
353-
"data": { "applicability": fix.applicability.to_string() }
391+
"title": refactoring.title,
392+
"kind": refactoring.kind.lsp_kind(),
393+
"edit": { "changes": changes }
354394
}));
355395
}
356396
}

‎src/lsp/tests.rs‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ fn advertises_full_sync_diagnostics_formatting_and_semantic_tokens() {
4141
response[0]["result"]["capabilities"]["codeActionProvider"]["codeActionKinds"][0],
4242
"quickfix"
4343
);
44+
assert_eq!(
45+
response[0]["result"]["capabilities"]["codeActionProvider"]["codeActionKinds"][1],
46+
"refactor.extract"
47+
);
4448
assert_eq!(
4549
response[0]["result"]["capabilities"]["semanticTokensProvider"]["full"],
4650
true
@@ -949,6 +953,144 @@ fn document_symbols_and_code_actions_preserve_compiler_structure() {
949953
assert!(unrelated[0]["result"].as_array().unwrap().is_empty());
950954
}
951955

956+
#[test]
957+
fn code_actions_extract_selected_expressions() {
958+
let source = concat!(
959+
"state \"game.exe\" {}\n",
960+
"fn score(offset: i32) {\n",
961+
" return offset + 1\n",
962+
"}\n"
963+
);
964+
let uri = "file:///refactor.split";
965+
let mut server = LanguageServer::default();
966+
initialize(&mut server);
967+
server.handle(notification(
968+
"textDocument/didOpen",
969+
json!({
970+
"textDocument": {
971+
"uri": uri,
972+
"version": 1,
973+
"text": source
974+
}
975+
}),
976+
));
977+
978+
let start = source.find("offset + 1").unwrap();
979+
let end = start + "offset + 1".len();
980+
let (start_line, start_character) = position_parts(source, start);
981+
let (end_line, end_character) = position_parts(source, end);
982+
let actions = server.handle(json!({
983+
"jsonrpc": "2.0",
984+
"id": 24,
985+
"method": "textDocument/codeAction",
986+
"params": {
987+
"textDocument": { "uri": uri },
988+
"range": {
989+
"start": { "line": start_line, "character": start_character },
990+
"end": { "line": end_line, "character": end_character }
991+
},
992+
"context": { "diagnostics": [], "only": ["refactor.extract"] }
993+
}
994+
}));
995+
let actions = actions[0]["result"].as_array().unwrap();
996+
assert_eq!(actions.len(), 2, "{actions:#?}");
997+
assert_eq!(actions[0]["kind"], "refactor.extract.variable");
998+
assert_eq!(actions[1]["kind"], "refactor.extract.function");
999+
assert_eq!(
1000+
actions[1]["edit"]["changes"][uri][0]["newText"],
1001+
"extracted(offset)"
1002+
);
1003+
1004+
let function_only = server.handle(json!({
1005+
"jsonrpc": "2.0",
1006+
"id": 25,
1007+
"method": "textDocument/codeAction",
1008+
"params": {
1009+
"textDocument": { "uri": uri },
1010+
"range": {
1011+
"start": { "line": start_line, "character": start_character },
1012+
"end": { "line": end_line, "character": end_character }
1013+
},
1014+
"context": {
1015+
"diagnostics": [],
1016+
"only": ["refactor.extract.function"]
1017+
}
1018+
}
1019+
}));
1020+
let function_only = function_only[0]["result"].as_array().unwrap();
1021+
assert_eq!(function_only.len(), 1);
1022+
assert_eq!(function_only[0]["kind"], "refactor.extract.function");
1023+
1024+
let quick_fixes = server.handle(json!({
1025+
"jsonrpc": "2.0",
1026+
"id": 26,
1027+
"method": "textDocument/codeAction",
1028+
"params": {
1029+
"textDocument": { "uri": uri },
1030+
"range": {
1031+
"start": { "line": start_line, "character": start_character },
1032+
"end": { "line": end_line, "character": end_character }
1033+
},
1034+
"context": { "diagnostics": [], "only": ["quickfix"] }
1035+
}
1036+
}));
1037+
assert!(quick_fixes[0]["result"].as_array().unwrap().is_empty());
1038+
1039+
let statement_source = concat!(
1040+
"state \"game.exe\" {}\n",
1041+
"fn report(value: i32) {\n",
1042+
" print(value)\n",
1043+
" print(value + 1)\n",
1044+
"}\n"
1045+
);
1046+
let statement_uri = "file:///statement-refactor.split";
1047+
server.handle(notification(
1048+
"textDocument/didOpen",
1049+
json!({
1050+
"textDocument": {
1051+
"uri": statement_uri,
1052+
"version": 1,
1053+
"text": statement_source
1054+
}
1055+
}),
1056+
));
1057+
let statement_start = statement_source.find("print(value)").unwrap();
1058+
let statement_end =
1059+
statement_source.find("print(value + 1)").unwrap() + "print(value + 1)".len();
1060+
let (statement_start_line, statement_start_character) =
1061+
position_parts(statement_source, statement_start);
1062+
let (statement_end_line, statement_end_character) =
1063+
position_parts(statement_source, statement_end);
1064+
let statements = server.handle(json!({
1065+
"jsonrpc": "2.0",
1066+
"id": 27,
1067+
"method": "textDocument/codeAction",
1068+
"params": {
1069+
"textDocument": { "uri": statement_uri },
1070+
"range": {
1071+
"start": {
1072+
"line": statement_start_line,
1073+
"character": statement_start_character
1074+
},
1075+
"end": {
1076+
"line": statement_end_line,
1077+
"character": statement_end_character
1078+
}
1079+
},
1080+
"context": {
1081+
"diagnostics": [],
1082+
"only": ["refactor.extract.function"]
1083+
}
1084+
}
1085+
}));
1086+
let statements = statements[0]["result"].as_array().unwrap();
1087+
assert_eq!(statements.len(), 1, "{statements:#?}");
1088+
assert_eq!(
1089+
statements[0]["edit"]["changes"][statement_uri][0]["newText"],
1090+
"extracted(value)"
1091+
);
1092+
}
1093+
9521094
#[test]
9531095
fn unused_member_code_actions_apply_validated_multi_edit_suppressions() {
9541096
let source = concat!(

0 commit comments

Comments
 (0)