Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -17,102 +17,116 @@

package com.itsaky.androidide.editor.language

import com.itsaky.androidide.app.BaseApplication
import com.itsaky.androidide.editor.R
import com.itsaky.androidide.lsp.api.ILanguageServer
import com.itsaky.androidide.lsp.models.CodeFormatException
import com.itsaky.androidide.lsp.models.CodeFormatResult
import com.itsaky.androidide.lsp.models.FormatCodeParams
import com.itsaky.androidide.models.Position
import com.itsaky.androidide.models.Range
import com.itsaky.androidide.utils.flashError
import io.github.rosemoe.sora.lang.format.AsyncFormatter
import io.github.rosemoe.sora.text.CharPosition
import io.github.rosemoe.sora.text.Content
import io.github.rosemoe.sora.text.TextRange
import org.slf4j.LoggerFactory

/**
* An [AsyncFormatter] implementation which uses the LSP implementation to format code.
*
* @author Akash Yadav
*/
class LSPFormatter(val server: ILanguageServer? = null) : AsyncFormatter() {

override fun formatAsync(text: Content, cursorRange: TextRange): TextRange {
return doFormat(text, cursorRange)
}

override fun formatRegionAsync(
text: Content,
rangeToFormat: TextRange,
cursorRange: TextRange
): TextRange {
return doFormat(text, cursorRange, rangeToFormat)
}

private fun doFormat(
text: Content,
cursorRange: TextRange,
rangeToFormat: TextRange? = null
): TextRange {
if (server == null) {
return cursorRange
}

val range =
(rangeToFormat?.asRange() ?: text.wholeRange()).apply {
start.apply {
index = (if (line == 0 && column == 0) 0 else text.getCharIndex(line, column))
}
end.apply { index = (if (line == 0 && column == 0) 0 else text.getCharIndex(line, column)) }
}
val result = server.formatCode(FormatCodeParams(text, range))

if (!result.hasEdits() ) {
// Deselect the selected content
return TextRange(cursorRange.start, cursorRange.start)
}

if (result.isIndexed) {
result.indexedTextEdits.forEach { text.replace(it.start, it.end, it.newText) }
} else {
result.edits.forEach {
text.replace(
it.range.start.line,
it.range.start.column,
it.range.end.line,
it.range.end.column,
it.newText
)
}
}
// Deselect the selected content
return TextRange(cursorRange.start, cursorRange.start)
}
}
class LSPFormatter(
val server: ILanguageServer? = null,
private val reportFailure: (Throwable) -> Unit = ::flashFormatFailure,
) : AsyncFormatter() {
override fun formatAsync(
text: Content,
cursorRange: TextRange,
): TextRange = doFormat(text, cursorRange)

private fun CodeFormatResult.hasEdits() =
this.indexedTextEdits.isNotEmpty() || this.edits.isNotEmpty()
override fun formatRegionAsync(
text: Content,
rangeToFormat: TextRange,
cursorRange: TextRange,
): TextRange = doFormat(text, cursorRange, rangeToFormat)

private fun TextRange.asRange(): Range {
return Range().also {
it.start = this.start.asPosition()
it.end = this.end.asPosition()
}
}
private fun doFormat(
text: Content,
cursorRange: TextRange,
rangeToFormat: TextRange? = null,
): TextRange {
if (server == null) {
return cursorRange
}

private fun Content.wholeRange(): Range {
return Range(Position(0, 0), Position(lineCount - 1, getColumnCount(lineCount - 1)))
}
val range =
(rangeToFormat?.asRange() ?: text.wholeRange()).apply {
start.apply {
index = (if (line == 0 && column == 0) 0 else text.getCharIndex(line, column))
}
end.apply { index = (if (line == 0 && column == 0) 0 else text.getCharIndex(line, column)) }
}
val result =
try {
server.formatCode(FormatCodeParams(text, range))
} catch (e: Throwable) {
reportFailure(e)
return TextRange(cursorRange.start, cursorRange.start)
}

private fun CharPosition.asPosition(): Position {
return Position(this.line, this.column, this.index)
}
if (!result.hasEdits()) {
// Deselect the selected content
return TextRange(cursorRange.start, cursorRange.start)
}

private fun Range.asTextRange(): TextRange {
return TextRange(this.start.asCharPosition(), this.end.asCharPosition())
if (result.isIndexed) {
result.indexedTextEdits.forEach { text.replace(it.start, it.end, it.newText) }
} else {
result.edits.forEach {
text.replace(
it.range.start.line,
it.range.start.column,
it.range.end.line,
it.range.end.column,
it.newText,
)
}
}
// Deselect the selected content
return TextRange(cursorRange.start, cursorRange.start)
}
}

private fun Position.asCharPosition(): CharPosition {
return CharPosition().also {
it.line = this.line
it.column = this.column
it.index = this.index
}
private val log = LoggerFactory.getLogger(LSPFormatter::class.java)

private fun flashFormatFailure(error: Throwable) {
if (error is CodeFormatException) {
flashError(BaseApplication.baseInstance.getString(R.string.msg_format_failed, error.message))
return
}
log.error("Failed to format code", error)
flashError(R.string.msg_format_failed_unexpected)
}

private fun CodeFormatResult.hasEdits() = this.indexedTextEdits.isNotEmpty() || this.edits.isNotEmpty()

private fun TextRange.asRange(): Range =
Range().also {
it.start = this.start.asPosition()
it.end = this.end.asPosition()
}

private fun Content.wholeRange(): Range = Range(Position(0, 0), Position(lineCount - 1, getColumnCount(lineCount - 1)))
Comment thread
jatezzz marked this conversation as resolved.

private fun CharPosition.asPosition(): Position = Position(this.line, this.column, this.index)

private fun Range.asTextRange(): TextRange = TextRange(this.start.asCharPosition(), this.end.asCharPosition())

private fun Position.asCharPosition(): CharPosition =
CharPosition().also {
it.line = this.line
it.column = this.column
it.index = this.index
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.itsaky.androidide.editor.language

import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.lsp.api.ILanguageServer
import io.github.rosemoe.sora.text.CharPosition
import io.github.rosemoe.sora.text.Content
import io.github.rosemoe.sora.text.TextRange
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class LSPFormatterTest {
@Test
fun `reports a server failure instead of letting it escape`() {
val failure = StackOverflowError()
val server = mockk<ILanguageServer> { every { formatCode(any()) } throws failure }
val reported = mutableListOf<Throwable>()
val text = Content("fun main(")
val cursor = TextRange(CharPosition(0, 3, 3), CharPosition(0, 3, 3))

val range = LSPFormatter(server, reported::add).formatAsync(text, cursor)

assertThat(reported).containsExactly(failure)
assertThat(text.toString()).isEqualTo("fun main(")
assertThat(range.start).isEqualTo(cursor.start)
assertThat(range.end).isEqualTo(cursor.start)
}
}
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ google-protobuf-java = { module = "com.google.protobuf:protobuf-javalite", versi
google-protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }
google-protobuf-gradle = { module = "com.google.protobuf:protobuf-gradle-plugin", version.ref = "protobuf-plugin" }
google-java-format = { module = "com.google.googlejavaformat:google-java-format", version = "1.20.0" }
ktfmt = { module = "com.facebook:ktfmt", version = "0.64" }
google-flexbox = { module = "com.google.android.flexbox:flexbox", version = "3.0.0" }

# AAPT2
Expand Down
10 changes: 10 additions & 0 deletions lsp/kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ dependencies {
implementation(libs.common.kotlin.coroutines.android)
implementation(libs.sentry.android.core)

implementation(libs.ktfmt) {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-embeddable")
exclude(group = "com.google.googlejavaformat", module = "google-java-format")
exclude(group = "com.google.guava", module = "guava")
exclude(group = "net.java.dev.jna", module = "jna")
exclude(group = "org.ec4j.core", module = "ec4j-core")
}
implementation(libs.composite.googleJavaFormat)
implementation(libs.google.guava)

compileOnly(projects.common)

testImplementation(projects.testing.lsp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,18 @@ import com.itsaky.androidide.lsp.kotlin.compiler.KotlinProjectModel
import com.itsaky.androidide.lsp.kotlin.completion.KotlinSnippetRepository
import com.itsaky.androidide.lsp.kotlin.completion.codeComplete
import com.itsaky.androidide.lsp.kotlin.diagnostic.collectDiagnosticsFor
import com.itsaky.androidide.lsp.kotlin.format.KotlinCodeFormatter
import com.itsaky.androidide.lsp.kotlin.navigation.findDefinitionAt
import com.itsaky.androidide.lsp.kotlin.navigation.findUsagesAt
import com.itsaky.androidide.lsp.kotlin.signaturehelp.doSignatureHelp
import com.itsaky.androidide.lsp.models.CodeFormatResult
import com.itsaky.androidide.lsp.models.CompletionParams
import com.itsaky.androidide.lsp.models.CompletionResult
import com.itsaky.androidide.lsp.models.DefinitionParams
import com.itsaky.androidide.lsp.models.DefinitionResult
import com.itsaky.androidide.lsp.models.DiagnosticResult
import com.itsaky.androidide.lsp.models.ExpandSelectionParams
import com.itsaky.androidide.lsp.models.FormatCodeParams
import com.itsaky.androidide.lsp.models.ReferenceParams
import com.itsaky.androidide.lsp.models.ReferenceResult
import com.itsaky.androidide.lsp.models.SignatureHelp
Expand Down Expand Up @@ -271,6 +274,8 @@ class KotlinLanguageServer : ILanguageServer {

override suspend fun expandSelection(params: ExpandSelectionParams): Range = params.selection

override fun formatCode(params: FormatCodeParams?): CodeFormatResult = KotlinCodeFormatter.format(requireNotNull(params).content)

override suspend fun signatureHelp(params: SignatureHelpParams): SignatureHelp {
if (!settings.signatureHelpEnabled()) {
return SignatureHelp.empty()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.itsaky.androidide.lsp.kotlin.format

import com.facebook.ktfmt.format.Formatter
import com.facebook.ktfmt.format.ParseError
import com.itsaky.androidide.lsp.models.CodeFormatException
import com.itsaky.androidide.lsp.models.CodeFormatResult

internal object KotlinCodeFormatter {
fun format(content: CharSequence): CodeFormatResult {
val formatted =
try {
Formatter.format(Formatter.KOTLINLANG_FORMAT, content.toString())
} catch (e: ParseError) {
val strippedShebangLines = if (content.startsWith("#!")) 1 else 0
val line = e.lineColumn.line + 1 + strippedShebangLines
throw CodeFormatException("$line:${e.lineColumn.column + 1}: ${e.errorDescription}", e)
}
return CodeFormatResult.forWholeContent(content, formatted)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.jetbrains.kotlin.cli.common.messages

fun interface MessageRenderer {
fun render(
severity: CompilerMessageSeverity,
message: String,
location: CompilerMessageSourceLocation?,
): String

companion object {
@JvmField
val PLAIN_RELATIVE_PATHS: MessageRenderer =
MessageRenderer { severity, message, location ->
if (location == null) {
"${severity.presentableName}: $message"
} else {
"${location.path}:${location.line}:${location.column}: ${severity.presentableName}: $message"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.jetbrains.kotlin.cli.common.messages

import java.io.PrintStream

class PrintingMessageCollector(
private val errStream: PrintStream,
private val messageRenderer: MessageRenderer,
private val verbose: Boolean,
) : MessageCollector {
private var hasErrors = false

override fun clear() = Unit

override fun report(
severity: CompilerMessageSeverity,
message: String,
location: CompilerMessageSourceLocation?,
) {
if (!verbose && severity in CompilerMessageSeverity.VERBOSE) return
hasErrors = hasErrors || severity.isError
errStream.println(messageRenderer.render(severity, message, location))
}

override fun hasErrors(): Boolean = hasErrors
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.itsaky.androidide.lsp.kotlin.format

import com.facebook.ktfmt.format.ParseError
import com.itsaky.androidide.lsp.models.CodeFormatException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class KotlinCodeFormatterTest {
@Test
fun `formats the whole file as one edit`() {
val source = "fun main( ) {println( \"hi\" )}\n"

val result = KotlinCodeFormatter.format(source)

val edit = result.indexedTextEdits.single()
assertTrue(result.isIndexed)
assertEquals(0, edit.start)
assertEquals(source.length, edit.end)
assertEquals("fun main() {\n println(\"hi\")\n}\n", edit.newText)
}

@Test
fun `reports a syntax error with its position`() {
val error =
assertThrows(CodeFormatException::class.java) {
KotlinCodeFormatter.format("fun main() {\n println(\"hi\"\n}\n")
}

assertTrue(error.cause is ParseError)
assertTrue(error.message, error.message!!.matches(Regex("""\d+:\d+: .+""")))
}

@Test
fun `counts the shebang line in a syntax error position`() {
val error =
assertThrows(CodeFormatException::class.java) {
KotlinCodeFormatter.format("#!/usr/bin/env kotlin\n\nfun broken( {\n println(\"hi\")\n}\n")
}

assertEquals("3:12: Expecting ')'", error.message)
}
}
Loading
Loading