Skip to content

Commit 02f9df5

Browse files
committed
Merge pull request #322 (abstract functions quickfix)
2 parents 8e52cc0 + 0d9b75f commit 02f9df5

File tree

9 files changed

+364
-17
lines changed

9 files changed

+364
-17
lines changed

server/src/main/kotlin/org/javacs/kt/KotlinTextDocumentService.kt

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import org.eclipse.lsp4j.*
44
import org.eclipse.lsp4j.jsonrpc.messages.Either
55
import org.eclipse.lsp4j.services.LanguageClient
66
import org.eclipse.lsp4j.services.TextDocumentService
7+
import org.javacs.kt.codeaction.codeActions
78
import org.javacs.kt.completion.*
89
import org.javacs.kt.definition.goToDefinition
910
import org.javacs.kt.diagnostic.convertDiagnostic
@@ -74,9 +75,13 @@ class KotlinTextDocumentService(
7475
}
7576

7677
private fun recover(position: TextDocumentPositionParams, recompile: Recompile): Pair<CompiledFile, Int> {
77-
val uri = parseURI(position.textDocument.uri)
78+
return recover(position.textDocument.uri, position.position, recompile)
79+
}
80+
81+
private fun recover(uriString: String, position: Position, recompile: Recompile): Pair<CompiledFile, Int> {
82+
val uri = parseURI(uriString)
7883
val content = sp.content(uri)
79-
val offset = offset(content, position.position.line, position.position.character)
84+
val offset = offset(content, position.line, position.character)
8085
val shouldRecompile = when (recompile) {
8186
Recompile.ALWAYS -> true
8287
Recompile.AFTER_DOT -> offset > 0 && content[offset - 1] == '.'
@@ -87,21 +92,8 @@ class KotlinTextDocumentService(
8792
}
8893

8994
override fun codeAction(params: CodeActionParams): CompletableFuture<List<Either<Command, CodeAction>>> = async.compute {
90-
val start = params.range.start
91-
val end = params.range.end
92-
val hasSelection = (end.character - start.character) != 0 || (end.line - start.line) != 0
93-
if (hasSelection) {
94-
listOf(
95-
Either.forLeft<Command, CodeAction>(
96-
Command("Convert Java to Kotlin", JAVA_TO_KOTLIN_COMMAND, listOf(
97-
params.textDocument.uri,
98-
params.range
99-
))
100-
)
101-
)
102-
} else {
103-
emptyList()
104-
}
95+
val (file, _) = recover(params.textDocument.uri, params.range.start, Recompile.NEVER)
96+
codeActions(file, params.range, params.context)
10597
}
10698

10799
override fun hover(position: HoverParams): CompletableFuture<Hover?> = async.compute {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package org.javacs.kt.codeaction
2+
3+
import org.eclipse.lsp4j.*
4+
import org.eclipse.lsp4j.jsonrpc.messages.Either
5+
import org.javacs.kt.CompiledFile
6+
import org.javacs.kt.codeaction.quickfixes.ImplementAbstractFunctionsQuickFix
7+
import org.javacs.kt.commands.JAVA_TO_KOTLIN_COMMAND
8+
import org.javacs.kt.util.toPath
9+
10+
val QUICK_FIXES = listOf(
11+
ImplementAbstractFunctionsQuickFix()
12+
)
13+
14+
fun codeActions(file: CompiledFile, range: Range, context: CodeActionContext): List<Either<Command, CodeAction>> {
15+
val requestedKinds = context.only ?: listOf(CodeActionKind.Refactor)
16+
return requestedKinds.map {
17+
when (it) {
18+
CodeActionKind.Refactor -> getRefactors(file, range)
19+
CodeActionKind.QuickFix -> getQuickFixes(file, range, context.diagnostics)
20+
else -> listOf()
21+
}
22+
}.flatten()
23+
}
24+
25+
fun getRefactors(file: CompiledFile, range: Range): List<Either<Command, CodeAction>> {
26+
val hasSelection = (range.end.line - range.start.line) != 0 || (range.end.character - range.start.character) != 0
27+
return if (hasSelection) {
28+
listOf(
29+
Either.forLeft<Command, CodeAction>(
30+
Command("Convert Java to Kotlin", JAVA_TO_KOTLIN_COMMAND, listOf(
31+
file.parse.toPath().toUri().toString(),
32+
range
33+
))
34+
)
35+
)
36+
} else {
37+
emptyList()
38+
}
39+
}
40+
41+
fun getQuickFixes(file: CompiledFile, range: Range, diagnostics: List<Diagnostic>): List<Either<Command, CodeAction>> {
42+
return QUICK_FIXES.mapNotNull {
43+
it.compute(file, range, diagnostics)
44+
}
45+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package org.javacs.kt.codeaction.quickfixes
2+
3+
import org.eclipse.lsp4j.*
4+
import org.eclipse.lsp4j.jsonrpc.messages.Either
5+
import org.javacs.kt.CompiledFile
6+
import org.javacs.kt.position.offset
7+
import org.javacs.kt.position.position
8+
import org.javacs.kt.util.toPath
9+
import org.jetbrains.kotlin.js.resolve.diagnostics.findPsi
10+
import org.jetbrains.kotlin.lexer.KtTokens
11+
import org.jetbrains.kotlin.psi.KtClass
12+
import org.jetbrains.kotlin.psi.KtDeclaration
13+
import org.jetbrains.kotlin.psi.KtNamedFunction
14+
import org.jetbrains.kotlin.psi.psiUtil.containingClass
15+
import org.jetbrains.kotlin.psi.psiUtil.endOffset
16+
import org.jetbrains.kotlin.psi.psiUtil.isAbstract
17+
import org.jetbrains.kotlin.psi.psiUtil.startOffset
18+
import org.jetbrains.kotlin.resolve.diagnostics.Diagnostics
19+
20+
private const val DEFAULT_TAB_SIZE = 4
21+
22+
class ImplementAbstractFunctionsQuickFix : QuickFix {
23+
24+
override fun compute(file: CompiledFile, range: Range, diagnostics: List<Diagnostic>): Either<Command, CodeAction>? {
25+
val diagnostic = findDiagnosticMatch(diagnostics, range)
26+
27+
val startCursor = offset(file.content, range.start)
28+
val endCursor = offset(file.content, range.end)
29+
val kotlinDiagnostics = file.compile.diagnostics
30+
31+
// If the client side and the server side diagnostics contain a valid diagnostic for this range.
32+
if (diagnostic != null && anyDiagnosticMatch(kotlinDiagnostics, startCursor, endCursor)) {
33+
// Get the class with the missing functions
34+
val kotlinClass = file.parseAtPoint(startCursor)
35+
if (kotlinClass is KtClass) {
36+
// Get the functions that need to be implemented
37+
val functionsToImplement = getAbstractFunctionStubs(file, kotlinClass)
38+
39+
val uri = file.parse.toPath().toUri().toString()
40+
// Get the padding to be introduced before the function declarations
41+
val padding = getDeclarationPadding(file, kotlinClass)
42+
// Get the location where the new code will be placed
43+
val newFunctionStartPosition = getNewFunctionStartPosition(file, kotlinClass)
44+
45+
val textEdits = functionsToImplement.map {
46+
// We leave two new lines before the function is inserted
47+
val newText = System.lineSeparator() + System.lineSeparator() + padding + it
48+
TextEdit(Range(newFunctionStartPosition, newFunctionStartPosition), newText)
49+
}
50+
51+
val codeAction = CodeAction()
52+
codeAction.edit = WorkspaceEdit(mapOf(uri to textEdits))
53+
codeAction.kind = CodeActionKind.QuickFix
54+
codeAction.title = "Implement abstract functions"
55+
codeAction.diagnostics = listOf(diagnostic)
56+
return Either.forRight(codeAction)
57+
}
58+
}
59+
return null
60+
}
61+
}
62+
63+
fun findDiagnosticMatch(diagnostics: List<Diagnostic>, range: Range) =
64+
diagnostics.find { diagnosticMatch(it, range, hashSetOf("ABSTRACT_MEMBER_NOT_IMPLEMENTED", "ABSTRACT_CLASS_MEMBER_NOT_IMPLEMENTED")) }
65+
66+
private fun anyDiagnosticMatch(diagnostics: Diagnostics, startCursor: Int, endCursor: Int) =
67+
diagnostics.any { diagnosticMatch(it, startCursor, endCursor, hashSetOf("ABSTRACT_MEMBER_NOT_IMPLEMENTED", "ABSTRACT_CLASS_MEMBER_NOT_IMPLEMENTED")) }
68+
69+
private fun getAbstractFunctionStubs(file: CompiledFile, kotlinClass: KtClass) =
70+
// For each of the super types used by this class
71+
kotlinClass.superTypeListEntries.mapNotNull {
72+
// Find the definition of this super type
73+
val descriptor = file.referenceAtPoint(it.startOffset)?.second
74+
val superClass = descriptor?.findPsi()
75+
// If the super class is abstract or an interface
76+
if (superClass is KtClass && (superClass.isAbstract() || superClass.isInterface())) {
77+
// Get the abstract functions of this super type that are currently not implemented by this class
78+
val abstractFunctions = superClass.declarations.filter {
79+
declaration -> isAbstractFunction(declaration) && !overridesDeclaration(kotlinClass, declaration)
80+
}
81+
// Get stubs for each function
82+
abstractFunctions.map { function -> getFunctionStub(function as KtNamedFunction) }
83+
} else {
84+
null
85+
}
86+
}.flatten()
87+
88+
private fun isAbstractFunction(declaration: KtDeclaration): Boolean =
89+
declaration is KtNamedFunction && !declaration.hasBody()
90+
&& (declaration.containingClass()?.isInterface() ?: false || declaration.hasModifier(KtTokens.ABSTRACT_KEYWORD))
91+
92+
// Checks if the class overrides the given declaration
93+
private fun overridesDeclaration(kotlinClass: KtClass, declaration: KtDeclaration): Boolean =
94+
kotlinClass.declarations.any {
95+
if (it.name == declaration.name && it.hasModifier(KtTokens.OVERRIDE_KEYWORD)) {
96+
if (it is KtNamedFunction && declaration is KtNamedFunction) {
97+
parametersMatch(it, declaration)
98+
} else {
99+
true
100+
}
101+
} else {
102+
false
103+
}
104+
}
105+
106+
// Checks if two functions have matching parameters
107+
private fun parametersMatch(function: KtNamedFunction, functionDeclaration: KtNamedFunction): Boolean {
108+
if (function.valueParameters.size == functionDeclaration.valueParameters.size) {
109+
for (index in 0 until function.valueParameters.size) {
110+
if (function.valueParameters[index].name != functionDeclaration.valueParameters[index].name) {
111+
return false
112+
} else if (function.valueParameters[index].typeReference?.name != functionDeclaration.valueParameters[index].typeReference?.name) {
113+
return false
114+
}
115+
}
116+
117+
if (function.typeParameters.size == functionDeclaration.typeParameters.size) {
118+
for (index in 0 until function.typeParameters.size) {
119+
if (function.typeParameters[index].variance != functionDeclaration.typeParameters[index].variance) {
120+
return false
121+
}
122+
}
123+
}
124+
125+
return true
126+
}
127+
128+
return false
129+
}
130+
131+
private fun getFunctionStub(function: KtNamedFunction): String =
132+
"override fun" + function.text.substringAfter("fun") + " { }"
133+
134+
private fun getDeclarationPadding(file: CompiledFile, kotlinClass: KtClass): String {
135+
// If the class is not empty, the amount of padding is the same as the one in the last declaration of the class
136+
val paddingSize = if (kotlinClass.declarations.isNotEmpty()) {
137+
val lastFunctionStartOffset = kotlinClass.declarations.last().startOffset
138+
position(file.content, lastFunctionStartOffset).character
139+
} else {
140+
// Otherwise, we just use a default tab size in addition to any existing padding
141+
// on the class itself (note that the class could be inside another class, for example)
142+
position(file.content, kotlinClass.startOffset).character + DEFAULT_TAB_SIZE
143+
}
144+
145+
return " ".repeat(paddingSize)
146+
}
147+
148+
private fun getNewFunctionStartPosition(file: CompiledFile, kotlinClass: KtClass): Position? =
149+
// If the class is not empty, the new function will be put right after the last declaration
150+
if (kotlinClass.declarations.isNotEmpty()) {
151+
val lastFunctionEndOffset = kotlinClass.declarations.last().endOffset
152+
position(file.content, lastFunctionEndOffset)
153+
} else { // Otherwise, the function is put at the beginning of the class
154+
val body = kotlinClass.body
155+
if (body != null) {
156+
position(file.content, body.startOffset + 1)
157+
} else {
158+
null
159+
}
160+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package org.javacs.kt.codeaction.quickfixes
2+
3+
import org.eclipse.lsp4j.CodeAction
4+
import org.eclipse.lsp4j.Command
5+
import org.eclipse.lsp4j.Diagnostic
6+
import org.eclipse.lsp4j.Range
7+
import org.eclipse.lsp4j.jsonrpc.messages.Either
8+
import org.javacs.kt.CompiledFile
9+
import org.jetbrains.kotlin.resolve.diagnostics.Diagnostics
10+
import org.jetbrains.kotlin.diagnostics.Diagnostic as KotlinDiagnostic
11+
12+
interface QuickFix {
13+
14+
// Computes the quickfix. Return null if the quickfix is not valid.
15+
fun compute(file: CompiledFile, range: Range, diagnostics: List<Diagnostic>): Either<Command, CodeAction>?
16+
}
17+
18+
fun diagnosticMatch(diagnostic: Diagnostic, range: Range, diagnosticTypes: HashSet<String>): Boolean =
19+
diagnostic.range.equals(range) && diagnosticTypes.contains(diagnostic.code.left)
20+
21+
fun diagnosticMatch(diagnostic: KotlinDiagnostic, startCursor: Int, endCursor: Int, diagnosticTypes: HashSet<String>): Boolean =
22+
diagnostic.textRanges.any { it.startOffset == startCursor && it.endOffset == endCursor } && diagnosticTypes.contains(diagnostic.factory.name)
23+
24+
fun findDiagnosticMatch(diagnostics: List<Diagnostic>, range: Range, diagnosticTypes: HashSet<String>) =
25+
diagnostics.find { diagnosticMatch(it, range, diagnosticTypes) }
26+
27+
fun anyDiagnosticMatch(diagnostics: Diagnostics, startCursor: Int, endCursor: Int, diagnosticTypes: HashSet<String>) =
28+
diagnostics.any { diagnosticMatch(it, startCursor, endCursor, diagnosticTypes) }

server/src/test/kotlin/org/javacs/kt/LanguageServerTestFixture.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ abstract class LanguageServerTestFixture(relativeWorkspaceRoot: String) : Langua
6868
fun textDocumentPosition(relativePath: String, line: Int, column: Int): TextDocumentPositionParams =
6969
textDocumentPosition(relativePath, position(line, column))
7070

71+
fun codeActionParams(relativePath: String, startLine: Int, startColumn: Int, endLine: Int, endColumn: Int, diagnostics: List<Diagnostic>, only: List<String>): CodeActionParams {
72+
val file = workspaceRoot.resolve(relativePath)
73+
val fileId = TextDocumentIdentifier(file.toUri().toString())
74+
val range = range(startLine, startColumn, endLine, endColumn)
75+
val context = CodeActionContext(diagnostics, only)
76+
77+
return CodeActionParams(fileId, range, context)
78+
}
79+
7180
fun hoverParams(relativePath: String, line: Int, column: Int): HoverParams =
7281
textDocumentPosition(relativePath, line, column).run { HoverParams(textDocument, position) }
7382

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package org.javacs.kt
2+
3+
import org.eclipse.lsp4j.CodeActionKind
4+
import org.eclipse.lsp4j.Diagnostic
5+
import org.eclipse.lsp4j.jsonrpc.messages.Either
6+
import org.hamcrest.Matchers
7+
import org.junit.Assert
8+
import org.junit.Test
9+
10+
class ImplementAbstractFunctionsQuickFixTest : SingleFileTestFixture("quickfixes", "SomeSubclass.kt") {
11+
@Test
12+
fun `gets workspace edit for all abstract methods when none are implemented`() {
13+
val diagnostic = Diagnostic(range(3, 1, 3, 19), "")
14+
diagnostic.code = Either.forLeft("ABSTRACT_CLASS_MEMBER_NOT_IMPLEMENTED")
15+
val codeActionParams = codeActionParams(file, 3, 1, 3, 19, listOf(diagnostic), listOf(CodeActionKind.QuickFix))
16+
17+
val codeActions = languageServer.textDocumentService.codeAction(codeActionParams).get()
18+
19+
Assert.assertThat(codeActions.size, Matchers.equalTo(1))
20+
Assert.assertThat(codeActions[0].right.kind, Matchers.equalTo(CodeActionKind.QuickFix))
21+
Assert.assertThat(codeActions[0].right.diagnostics.size, Matchers.equalTo(1))
22+
Assert.assertThat(codeActions[0].right.diagnostics[0], Matchers.equalTo(diagnostic))
23+
Assert.assertThat(codeActions[0].right.edit.changes.size, Matchers.equalTo(1))
24+
Assert.assertThat(codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.size, Matchers.equalTo(2))
25+
Assert.assertThat(
26+
codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.get(0)?.range,
27+
Matchers.equalTo(range(3, 55, 3, 55))
28+
)
29+
Assert.assertThat(
30+
codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.get(0)?.newText,
31+
Matchers.equalTo(System.lineSeparator() + System.lineSeparator() + " override fun someSuperMethod(someParameter: String): Int { }")
32+
)
33+
Assert.assertThat(
34+
codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.get(1)?.range,
35+
Matchers.equalTo(range(3, 55, 3, 55))
36+
)
37+
Assert.assertThat(
38+
codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.get(1)?.newText,
39+
Matchers.equalTo(System.lineSeparator() + System.lineSeparator() + " override fun someInterfaceMethod() { }")
40+
)
41+
}
42+
43+
@Test
44+
fun `gets workspace edit for interface methods when super class abstract methods are implemented`() {
45+
val diagnostic = Diagnostic(range(6, 1, 6, 24), "")
46+
diagnostic.code = Either.forLeft("ABSTRACT_MEMBER_NOT_IMPLEMENTED")
47+
val codeActionParams = codeActionParams(file, 6, 1, 6, 24, listOf(diagnostic), listOf(CodeActionKind.QuickFix))
48+
49+
val codeActions = languageServer.textDocumentService.codeAction(codeActionParams).get()
50+
51+
Assert.assertThat(codeActions.size, Matchers.equalTo(1))
52+
Assert.assertThat(codeActions[0].right.kind, Matchers.equalTo(CodeActionKind.QuickFix))
53+
Assert.assertThat(codeActions[0].right.diagnostics.size, Matchers.equalTo(1))
54+
Assert.assertThat(codeActions[0].right.diagnostics[0], Matchers.equalTo(diagnostic))
55+
Assert.assertThat(codeActions[0].right.edit.changes.size, Matchers.equalTo(1))
56+
Assert.assertThat(codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.size, Matchers.equalTo(1))
57+
Assert.assertThat(
58+
codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.get(0)?.range,
59+
Matchers.equalTo(range(7, 74, 7, 74))
60+
)
61+
Assert.assertThat(
62+
codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.get(0)?.newText,
63+
Matchers.equalTo(System.lineSeparator() + System.lineSeparator() + " override fun someInterfaceMethod() { }")
64+
)
65+
}
66+
67+
@Test
68+
fun `gets workspace edit for super class abstract methods when interface methods are implemented`() {
69+
val diagnostic = Diagnostic(range(10, 1, 10, 25), "")
70+
diagnostic.code = Either.forLeft("ABSTRACT_CLASS_MEMBER_NOT_IMPLEMENTED")
71+
val codeActionParams = codeActionParams(file, 10, 1, 10, 25, listOf(diagnostic), listOf(CodeActionKind.QuickFix))
72+
73+
val codeActions = languageServer.textDocumentService.codeAction(codeActionParams).get()
74+
75+
Assert.assertThat(codeActions.size, Matchers.equalTo(1))
76+
Assert.assertThat(codeActions[0].right.kind, Matchers.equalTo(CodeActionKind.QuickFix))
77+
Assert.assertThat(codeActions[0].right.diagnostics.size, Matchers.equalTo(1))
78+
Assert.assertThat(codeActions[0].right.diagnostics[0], Matchers.equalTo(diagnostic))
79+
Assert.assertThat(codeActions[0].right.edit.changes.size, Matchers.equalTo(1))
80+
Assert.assertThat(codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.size, Matchers.equalTo(1))
81+
Assert.assertThat(
82+
codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.get(0)?.range,
83+
Matchers.equalTo(range(11, 43, 11, 43))
84+
)
85+
Assert.assertThat(
86+
codeActions[0].right.edit.changes[codeActionParams.textDocument.uri]?.get(0)?.newText,
87+
Matchers.equalTo(System.lineSeparator() + System.lineSeparator() + " override fun someSuperMethod(someParameter: String): Int { }")
88+
)
89+
}
90+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package quickfixes
2+
3+
interface SomeInterface {
4+
fun someInterfaceMethod()
5+
}

0 commit comments

Comments
 (0)