diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/LSPFormatter.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/LSPFormatter.kt index 2269751fe5..b5251b3d3f 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/LSPFormatter.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/LSPFormatter.kt @@ -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))) + +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 + } diff --git a/editor/src/test/java/com/itsaky/androidide/editor/language/LSPFormatterTest.kt b/editor/src/test/java/com/itsaky/androidide/editor/language/LSPFormatterTest.kt new file mode 100644 index 0000000000..49ca855a8f --- /dev/null +++ b/editor/src/test/java/com/itsaky/androidide/editor/language/LSPFormatterTest.kt @@ -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 { every { formatCode(any()) } throws failure } + val reported = mutableListOf() + 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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c37e9080a5..def9994279 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index 2a88d969d7..520d9438c2 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -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) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index cf6eecb152..66be6a55a0 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -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 @@ -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() diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatter.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatter.kt new file mode 100644 index 0000000000..3bf84e0139 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatter.kt @@ -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) + } +} diff --git a/lsp/kotlin/src/main/java/org/jetbrains/kotlin/cli/common/messages/MessageRenderer.kt b/lsp/kotlin/src/main/java/org/jetbrains/kotlin/cli/common/messages/MessageRenderer.kt new file mode 100644 index 0000000000..cc0b92b2f5 --- /dev/null +++ b/lsp/kotlin/src/main/java/org/jetbrains/kotlin/cli/common/messages/MessageRenderer.kt @@ -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" + } + } + } +} diff --git a/lsp/kotlin/src/main/java/org/jetbrains/kotlin/cli/common/messages/PrintingMessageCollector.kt b/lsp/kotlin/src/main/java/org/jetbrains/kotlin/cli/common/messages/PrintingMessageCollector.kt new file mode 100644 index 0000000000..7fbdffe511 --- /dev/null +++ b/lsp/kotlin/src/main/java/org/jetbrains/kotlin/cli/common/messages/PrintingMessageCollector.kt @@ -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 +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatterTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatterTest.kt new file mode 100644 index 0000000000..05f49edb96 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatterTest.kt @@ -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) + } +} diff --git a/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/CodeFormatException.kt b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/CodeFormatException.kt new file mode 100644 index 0000000000..db1efea645 --- /dev/null +++ b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/CodeFormatException.kt @@ -0,0 +1,6 @@ +package com.itsaky.androidide.lsp.models + +class CodeFormatException( + message: String, + cause: Throwable, +) : Exception(message, cause) diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index e5d680d24c..f47f50c949 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -412,6 +412,8 @@ File tree Attribute value Format code + Could not format the file: %1$s + Could not format the file. See IDE logs for details. Help Show more More information