From aca5b95af00564da897f4ab0372a646390ee757d Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 1 Oct 2026 02:11:38 +0100 Subject: [PATCH 1/4] style: spotless reformat, no functional change --- .../editor/language/LSPFormatter.kt | 135 ++++++++---------- 1 file changed, 63 insertions(+), 72 deletions(-) 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..c9d83da557 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 @@ -32,87 +32,78 @@ import io.github.rosemoe.sora.text.TextRange * * @author Akash Yadav */ -class LSPFormatter(val server: ILanguageServer? = null) : AsyncFormatter() { - - override fun formatAsync(text: Content, cursorRange: TextRange): TextRange { - return doFormat(text, cursorRange) - } +class LSPFormatter( + val server: ILanguageServer? = null, +) : AsyncFormatter() { + override fun formatAsync( + text: Content, + cursorRange: TextRange, + ): TextRange = doFormat(text, cursorRange) - override fun formatRegionAsync( - text: Content, - rangeToFormat: TextRange, - cursorRange: TextRange - ): TextRange { - return doFormat(text, cursorRange, rangeToFormat) - } + override fun formatRegionAsync( + text: Content, + rangeToFormat: TextRange, + cursorRange: TextRange, + ): TextRange = doFormat(text, cursorRange, rangeToFormat) - private fun doFormat( - text: Content, - cursorRange: TextRange, - rangeToFormat: TextRange? = null - ): TextRange { - if (server == null) { - return cursorRange - } + 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)) + 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.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) - } + 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 CodeFormatResult.hasEdits() = - this.indexedTextEdits.isNotEmpty() || this.edits.isNotEmpty() +private fun CodeFormatResult.hasEdits() = this.indexedTextEdits.isNotEmpty() || this.edits.isNotEmpty() -private fun TextRange.asRange(): Range { - return Range().also { - it.start = this.start.asPosition() - it.end = this.end.asPosition() - } -} +private fun TextRange.asRange(): Range = + Range().also { + it.start = this.start.asPosition() + it.end = this.end.asPosition() + } -private fun Content.wholeRange(): Range { - return Range(Position(0, 0), Position(lineCount - 1, getColumnCount(lineCount - 1))) -} +private fun Content.wholeRange(): Range = Range(Position(0, 0), Position(lineCount - 1, getColumnCount(lineCount - 1))) -private fun CharPosition.asPosition(): Position { - return Position(this.line, this.column, this.index) -} +private fun CharPosition.asPosition(): Position = Position(this.line, this.column, this.index) -private fun Range.asTextRange(): TextRange { - return TextRange(this.start.asCharPosition(), this.end.asCharPosition()) -} +private fun Range.asTextRange(): TextRange = TextRange(this.start.asCharPosition(), this.end.asCharPosition()) -private fun Position.asCharPosition(): CharPosition { - return CharPosition().also { - it.line = this.line - it.column = this.column - it.index = this.index - } -} +private fun Position.asCharPosition(): CharPosition = + CharPosition().also { + it.line = this.line + it.column = this.column + it.index = this.index + } From 480e21c9f51622c05028c781c944b2ff4774e573 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 1 Oct 2026 02:11:51 +0100 Subject: [PATCH 2/4] ADFA-3021: Format Kotlin with ktfmt The Kotlin language server never overrode formatCode, so Format code did nothing on .kt files. It now formats the whole file with ktfmt in the kotlinlang style. ktfmt links against the shipped kt-android jar instead of its own kotlin-compiler-embeddable. google-java-format and guava resolve to the copies the app already ships, and jna and ec4j (CLI only) are dropped, so the only new code is the ktfmt jar itself (about 300 KB of dex). ktfmt's Parser builds its compiler environment with PrintingMessageCollector and MessageRenderer.PLAIN_RELATIVE_PATHS. Those live in the compiler's cli-common module, which the kt-android jar does not include, so ktfmt failed with NoClassDefFoundError. lsp/kotlin now provides both under their upstream names. All other ktfmt references resolve against the shipped jars. If a later kt-android release adds them, the duplicate class fails the build and these two files go. A syntax error surfaces as a CodeFormatException carrying the line:column and parser message. --- gradle/libs.versions.toml | 1 + lsp/kotlin/build.gradle.kts | 10 +++++ .../lsp/kotlin/KotlinLanguageServer.kt | 5 +++ .../lsp/kotlin/format/KotlinCodeFormatter.kt | 18 +++++++++ .../cli/common/messages/MessageRenderer.kt | 21 +++++++++++ .../messages/PrintingMessageCollector.kt | 25 +++++++++++++ .../kotlin/format/KotlinCodeFormatterTest.kt | 37 +++++++++++++++++++ .../lsp/models/CodeFormatException.kt | 6 +++ 8 files changed, 123 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatter.kt create mode 100644 lsp/kotlin/src/main/java/org/jetbrains/kotlin/cli/common/messages/MessageRenderer.kt create mode 100644 lsp/kotlin/src/main/java/org/jetbrains/kotlin/cli/common/messages/PrintingMessageCollector.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatterTest.kt create mode 100644 lsp/models/src/main/java/com/itsaky/androidide/lsp/models/CodeFormatException.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a0d5ac5e88..dcf7f506ee 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..ad87fa9ba6 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatter.kt @@ -0,0 +1,18 @@ +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) { + throw CodeFormatException("${e.lineColumn.line + 1}:${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..78d5b89283 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatterTest.kt @@ -0,0 +1,37 @@ +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+: .+"""))) + } +} 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) From 6176b40bda303273fccb8483a800f83b0fcab556 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 1 Oct 2026 02:12:02 +0100 Subject: [PATCH 3/4] ADFA-3021: Report a failed format instead of doing nothing LSPFormatter now catches any Throwable from formatCode and reports it: a CodeFormatException flashes "Could not format the file: ", anything else is logged and flashes a pointer to the IDE logs. The text is left as it was. Letting the failure reach sora is not an option. AsyncFormatter's thread catches only Exception, and does so outside its loop while it holds its ReentrantLock, so a throwing formatAsync kills the thread with the lock held. The next format then hangs silently, and the one after blocks the main thread in lock.lock() (ANR). An Error, such as a StackOverflowError on deeply nested code, would escape the thread entirely. The failure reporter is a constructor parameter so the test can capture it without an Activity. --- .../editor/language/LSPFormatter.kt | 25 ++++++++++++++- .../editor/language/LSPFormatterTest.kt | 31 +++++++++++++++++++ resources/src/main/res/values/strings.xml | 2 ++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 editor/src/test/java/com/itsaky/androidide/editor/language/LSPFormatterTest.kt 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 c9d83da557..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,15 +17,20 @@ 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. @@ -34,6 +39,7 @@ import io.github.rosemoe.sora.text.TextRange */ class LSPFormatter( val server: ILanguageServer? = null, + private val reportFailure: (Throwable) -> Unit = ::flashFormatFailure, ) : AsyncFormatter() { override fun formatAsync( text: Content, @@ -62,7 +68,13 @@ class LSPFormatter( } end.apply { index = (if (line == 0 && column == 0) 0 else text.getCharIndex(line, column)) } } - val result = server.formatCode(FormatCodeParams(text, range)) + val result = + try { + server.formatCode(FormatCodeParams(text, range)) + } catch (e: Throwable) { + reportFailure(e) + return TextRange(cursorRange.start, cursorRange.start) + } if (!result.hasEdits()) { // Deselect the selected content @@ -87,6 +99,17 @@ class LSPFormatter( } } +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 = 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/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index f31bb89210..dccf84eb5b 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 From 81f5bda2c4416552131c145a106c6bce03c697b3 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 1 Oct 2026 16:04:40 +0100 Subject: [PATCH 4/4] ADFA-3021: Count a stripped shebang line in parse error positions ktfmt drops a leading #! line before parsing, so a syntax error in a script with a shebang was reported one line early. --- .../lsp/kotlin/format/KotlinCodeFormatter.kt | 4 +++- .../lsp/kotlin/format/KotlinCodeFormatterTest.kt | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 index ad87fa9ba6..3bf84e0139 100644 --- 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 @@ -11,7 +11,9 @@ internal object KotlinCodeFormatter { try { Formatter.format(Formatter.KOTLINLANG_FORMAT, content.toString()) } catch (e: ParseError) { - throw CodeFormatException("${e.lineColumn.line + 1}:${e.lineColumn.column + 1}: ${e.errorDescription}", e) + 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/test/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatterTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/format/KotlinCodeFormatterTest.kt index 78d5b89283..05f49edb96 100644 --- 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 @@ -34,4 +34,14 @@ class KotlinCodeFormatterTest { 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) + } }