From 2c6bd28e9884834cf916858b9461e0de789445e8 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 00:08:09 -0400 Subject: [PATCH 01/17] Add parser support for --lines, --offset, --length --- .../java/com/facebook/ktfmt/cli/ParsedArgs.kt | 141 +++++++++++++++++- .../com/facebook/ktfmt/cli/ParsedArgsTest.kt | 122 ++++++++++++++- 2 files changed, 258 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt index 949dd57a..c044c8c0 100644 --- a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt +++ b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt @@ -19,6 +19,9 @@ package com.facebook.ktfmt.cli import com.facebook.ktfmt.format.Formatter import com.facebook.ktfmt.format.FormattingOptions import com.facebook.ktfmt.util.Ktfmt +import com.google.common.collect.Range +import com.google.common.collect.RangeSet +import com.google.common.collect.TreeRangeSet import java.io.File import java.nio.charset.StandardCharsets.UTF_8 @@ -40,6 +43,11 @@ data class ParsedArgs( /** Suppress all non-error output. */ val quiet: Boolean, ) { + /** Zero-indexed line ranges to format, using closed-open bounds, e.g. [0, 3) and [6, 7). */ + internal val lineRanges: RangeSet = TreeRangeSet.create() + /** Zero-indexed character ranges to format, using closed-open bounds. */ + internal val characterRanges: RangeSet = TreeRangeSet.create() + companion object { fun processArgs(args: Array): ParseResult { @@ -81,6 +89,13 @@ data class ParsedArgs( | --google-style Google internal style (2 spaces) | --kotlinlang-style Kotlin language guidelines style (4 spaces) | --stdin-name= Name to report when formatting code from stdin + | --lines= Line range(s) to format, like 5 or 1:12,14. + | May be used multiple times. + | --offset= Character offset to format, paired with --length. + | May be used multiple times. + | --length= Character length to format, paired with --offset. + | May be used multiple times. 0 formats the line + | under the cursor. | --set-exit-if-changed Sets exit code to 1 if any input file was not | formatted/touched | --do-not-remove-unused-imports Leaves all imports in place, even if not used @@ -114,13 +129,26 @@ data class ParsedArgs( var stdinName: String? = null var editorConfig = false var quiet = false + val lineRanges = TreeRangeSet.create() + val offsets = mutableListOf() + val lengths = mutableListOf() if ("--help" in args || "-h" in args) return ParseResult.ShowMessage(HELP_TEXT) if ("--version" in args || "-v" in args) { return ParseResult.ShowMessage("ktfmt version ${Ktfmt.version}") } - for (arg in args) { + var i = 0 + while (i < args.size) { + val arg = args[i] + val nextValue = { + i++ + if (i == args.size) { + null + } else { + args[i] + } + } when { arg == "--meta-style" -> formattingOptions = Formatter.META_FORMAT arg == "--google-style" -> formattingOptions = Formatter.GOOGLE_FORMAT @@ -136,10 +164,51 @@ data class ParsedArgs( ?: return ParseResult.Error( "Found option '${arg}', expected '${"--stdin-name"}='" ) + arg == "--lines" || arg == "--line" -> { + val value = + nextValue() ?: return ParseResult.Error("required value was not provided for: $arg") + when (val result = parseLineRanges(lineRanges, value)) { + LineRangeParseResult.Success -> Unit + is LineRangeParseResult.Error -> return ParseResult.Error(result.message) + } + } + arg.startsWith("--lines=") -> + when (val result = parseLineRanges(lineRanges, parseKeyValueArg("--lines", arg))) { + LineRangeParseResult.Success -> Unit + is LineRangeParseResult.Error -> return ParseResult.Error(result.message) + } + arg.startsWith("--line=") -> + when (val result = parseLineRanges(lineRanges, parseKeyValueArg("--line", arg))) { + LineRangeParseResult.Success -> Unit + is LineRangeParseResult.Error -> return ParseResult.Error(result.message) + } + arg == "--offset" -> { + val value = + nextValue() ?: return ParseResult.Error("required value was not provided for: $arg") + offsets.add(value.toIntOrNull() ?: return ParseResult.Error(invalidInt(arg, value))) + } + arg.startsWith("--offset=") -> + parseKeyValueArg("--offset", arg).let { value -> + offsets.add( + value.toIntOrNull() ?: return ParseResult.Error(invalidInt("--offset", value)) + ) + } + arg == "--length" -> { + val value = + nextValue() ?: return ParseResult.Error("required value was not provided for: $arg") + lengths.add(value.toIntOrNull() ?: return ParseResult.Error(invalidInt(arg, value))) + } + arg.startsWith("--length=") -> + parseKeyValueArg("--length", arg).let { value -> + lengths.add( + value.toIntOrNull() ?: return ParseResult.Error(invalidInt("--length", value)) + ) + } arg.startsWith("--") -> return ParseResult.Error("Unexpected option: $arg") arg.startsWith("@") -> return ParseResult.Error("Unexpected option: $arg") else -> fileNames.add(arg) } + i++ } if (fileNames.contains("-")) { @@ -155,7 +224,21 @@ data class ParsedArgs( return ParseResult.Error("--stdin-name can only be specified when reading from stdin") } - return ParseResult.Ok( + if (offsets.size != lengths.size) { + return ParseResult.Error("--offset and --length flags must be provided in matching pairs") + } + + val characterRanges = TreeRangeSet.create() + for (index in offsets.indices) { + val length = lengths[index].let { if (it == 0) 1 else it } + characterRanges.add(Range.closedOpen(offsets[index], offsets[index] + length)) + } + + if ((!lineRanges.isEmpty || !characterRanges.isEmpty) && fileNames.size != 1) { + return ParseResult.Error("partial formatting is only supported for a single file") + } + + val parsedArgs = ParsedArgs( fileNames, formattingOptions.copy(removeUnusedImports = removeUnusedImports), @@ -165,13 +248,65 @@ data class ParsedArgs( editorConfig, quiet, ) - ) + parsedArgs.lineRanges.addAll(lineRanges) + parsedArgs.characterRanges.addAll(characterRanges) + return ParseResult.Ok(parsedArgs) } private fun parseKeyValueArg(key: String, arg: String): String? { val parts = arg.split('=', limit = 2) return parts[1].takeIf { parts[0] == key || parts.size == 2 } } + + private fun String?.toIntOrNull(): Int? { + return try { + this?.toInt() + } catch (_: NumberFormatException) { + null + } + } + + private fun invalidInt(flag: String, value: String?): String = + "invalid integer value for $flag: $value" + + private fun parseLineRanges( + lineRanges: RangeSet, + lineRangesArg: String?, + ): LineRangeParseResult { + if (lineRangesArg == null) { + return LineRangeParseResult.Error("required value was not provided for: --lines") + } + return try { + for (lineRange in lineRangesArg.split(',')) { + lineRanges.add(parseLineRange(lineRange)) + } + LineRangeParseResult.Success + } catch (_: IllegalArgumentException) { + LineRangeParseResult.Error("invalid line range for --lines: $lineRangesArg") + } + } + + private sealed interface LineRangeParseResult { + data object Success : LineRangeParseResult + + @JvmInline value class Error(val message: String) : LineRangeParseResult + } + + private fun parseLineRange(arg: String): Range { + val parts = arg.split(':') + return when (parts.size) { + 1 -> { + val line = parts[0].toInt() - 1 + Range.closedOpen(line, line + 1) + } + 2 -> { + val line0 = parts[0].toInt() - 1 + val line1 = parts[1].toInt() - 1 + Range.closedOpen(line0, line1 + 1) + } + else -> throw IllegalArgumentException(arg) + } + } } } diff --git a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt index 72fdf72e..748381d9 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt @@ -18,6 +18,9 @@ package com.facebook.ktfmt.cli import com.facebook.ktfmt.format.Formatter import com.facebook.ktfmt.format.FormattingOptions +import com.google.common.collect.Range +import com.google.common.collect.RangeSet +import com.google.common.collect.TreeRangeSet import com.google.common.truth.Truth.assertThat import java.io.FileNotFoundException import kotlin.io.path.createTempDirectory @@ -121,6 +124,105 @@ class ParsedArgsTest { assertThat(parsed.stdinName).isEqualTo("my/foo.kt") } + @Test + fun `parseOptions recognizes --lines ranges`() { + val parsed = + assertSucceeds(ParsedArgs.parseOptions(arrayOf("--lines=1:3,5", "--lines", "7", "foo.kt"))) + + assertThat(parsed.lineRanges) + .isEqualTo( + lineRanges( + Range.closedOpen(0, 3), + Range.closedOpen(4, 5), + Range.closedOpen(6, 7), + ) + ) + } + + @Test + fun `parseOptions recognizes --line alias`() { + assertThat(ParsedArgs.parseOptions(arrayOf("--line=1", "foo.kt"))) + .isEqualTo( + parseResultOk( + fileNames = listOf("foo.kt"), + lineRanges = lineRanges(Range.closedOpen(0, 1)), + ) + ) + assertThat(assertSucceeds(ParsedArgs.parseOptions(arrayOf("--line", "2", "foo.kt"))).lineRanges) + .isEqualTo(lineRanges(Range.closedOpen(1, 2))) + } + + @Test + fun `parseOptions recognizes offset and length pairs`() { + val parsed = + assertSucceeds( + ParsedArgs.parseOptions( + arrayOf("--offset=10", "--length=5", "--offset", "20", "--length", "0", "foo.kt") + ) + ) + + assertThat(parsed.characterRanges) + .isEqualTo( + ranges( + Range.closedOpen(10, 15), + Range.closedOpen(20, 21), + ) + ) + } + + @Test + fun `parseOptions rejects --lines without value`() { + val parseResult = ParsedArgs.parseOptions(arrayOf("--lines")) + assertThat(parseResult) + .isEqualTo(ParseResult.Error("required value was not provided for: --lines")) + } + + @Test + fun `parseOptions rejects invalid --lines range`() { + val parseResult = ParsedArgs.parseOptions(arrayOf("--lines=not-a-line", "foo.kt")) + assertThat(parseResult) + .isEqualTo(ParseResult.Error("invalid line range for --lines: not-a-line")) + } + + @Test + fun `parseOptions rejects --offset without value`() { + val parseResult = ParsedArgs.parseOptions(arrayOf("--offset")) + assertThat(parseResult) + .isEqualTo(ParseResult.Error("required value was not provided for: --offset")) + } + + @Test + fun `parseOptions rejects invalid --offset`() { + val parseResult = + ParsedArgs.parseOptions(arrayOf("--offset=not-an-offset", "--length=1", "foo.kt")) + assertThat(parseResult) + .isEqualTo(ParseResult.Error("invalid integer value for --offset: not-an-offset")) + } + + @Test + fun `parseOptions rejects mismatched --offset and --length counts`() { + val parseResult = ParsedArgs.parseOptions(arrayOf("--offset=1", "foo.kt")) + assertThat(parseResult) + .isEqualTo( + ParseResult.Error("--offset and --length flags must be provided in matching pairs") + ) + } + + @Test + fun `parseOptions rejects --lines with multiple files`() { + val parseResult = ParsedArgs.parseOptions(arrayOf("--lines=1", "foo.kt", "bar.kt")) + assertThat(parseResult) + .isEqualTo(ParseResult.Error("partial formatting is only supported for a single file")) + } + + @Test + fun `parseOptions rejects --offset with multiple files`() { + val parseResult = + ParsedArgs.parseOptions(arrayOf("--offset=1", "--length=1", "foo.kt", "bar.kt")) + assertThat(parseResult) + .isEqualTo(ParseResult.Error("partial formatting is only supported for a single file")) + } + @Test fun `parseOptions accepts --stdin-name with empty value`() { val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--stdin-name=", "-"))) @@ -258,10 +360,12 @@ class ParsedArgsTest { stdinName: String? = null, editorConfig: Boolean = false, quiet: Boolean = false, + lineRanges: RangeSet = TreeRangeSet.create(), + characterRanges: RangeSet = TreeRangeSet.create(), ): ParseResult.Ok { val returnedFormattingOptions = formattingOptions.copy(removeUnusedImports = removedUnusedImports) - return ParseResult.Ok( + val parsedArgs = ParsedArgs( fileNames, returnedFormattingOptions, @@ -271,6 +375,20 @@ class ParsedArgsTest { editorConfig, quiet, ) - ) + parsedArgs.lineRanges.addAll(lineRanges) + parsedArgs.characterRanges.addAll(characterRanges) + return ParseResult.Ok(parsedArgs) + } + + private fun lineRanges(vararg ranges: Range): RangeSet { + return ranges(*ranges) + } + + private fun ranges(vararg ranges: Range): RangeSet { + val lineRanges = TreeRangeSet.create() + for (range in ranges) { + lineRanges.add(range) + } + return lineRanges } } From 5b062c33e177161bd2570c4f10da6585d2765962 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 00:34:04 -0400 Subject: [PATCH 02/17] Add nested markForPartialFormat boundaries --- .../java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index ed52de53..280b73ea 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -436,7 +436,9 @@ class KotlinInputAstVisitor( builder.blankLineWanted(OpsBuilder.BlankLineWanted.PRESERVE) } first = false + builder.markForPartialFormat() visitStatement(statement) + builder.markForPartialFormat() } } @@ -2696,7 +2698,9 @@ class KotlinInputAstVisitor( } ) + builder.markForPartialFormat() visit(child) + builder.markForPartialFormat() isFirst = false } markForPartialFormat() @@ -2722,8 +2726,10 @@ class KotlinInputAstVisitor( ) { builder.blankLineWanted(OpsBuilder.BlankLineWanted.YES) } + builder.markForPartialFormat() visit(child) builder.guessToken(";") + builder.markForPartialFormat() lastChildHadBlankLineBefore = childGetsBlankLineBefore lastChildIsContextReceiver = child is KtScriptInitializer && From 272e1f9df76dcf8764cab00b780680f7a9375c11 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 00:34:21 -0400 Subject: [PATCH 03/17] Wire partial formatting ranges into Formatter --- core/api/ktfmt.api | 3 + .../com/facebook/ktfmt/format/Formatter.kt | 157 ++++++++++++++++-- 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/core/api/ktfmt.api b/core/api/ktfmt.api index 9999a4bf..3558d14d 100644 --- a/core/api/ktfmt.api +++ b/core/api/ktfmt.api @@ -116,8 +116,11 @@ public final class com/facebook/ktfmt/format/Formatter { public static final field KOTLINLANG_FORMAT Lcom/facebook/ktfmt/format/FormattingOptions; public static final field META_FORMAT Lcom/facebook/ktfmt/format/FormattingOptions; public static final fun format (Lcom/facebook/ktfmt/format/FormattingOptions;Ljava/lang/String;)Ljava/lang/String; + public static final fun format (Lcom/facebook/ktfmt/format/FormattingOptions;Ljava/lang/String;Lcom/google/common/collect/RangeSet;)Ljava/lang/String; + public static final fun format (Lcom/facebook/ktfmt/format/FormattingOptions;Ljava/lang/String;Lcom/google/common/collect/RangeSet;Lcom/google/common/collect/RangeSet;)Ljava/lang/String; public static final fun format (Ljava/lang/String;)Ljava/lang/String; public static final fun format (Ljava/lang/String;Z)Ljava/lang/String; + public static synthetic fun format$default (Lcom/facebook/ktfmt/format/FormattingOptions;Ljava/lang/String;Lcom/google/common/collect/RangeSet;Lcom/google/common/collect/RangeSet;ILjava/lang/Object;)Ljava/lang/String; } public final class com/facebook/ktfmt/format/FormattingOptions { diff --git a/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt b/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt index 24864b5a..f63dfc19 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt @@ -24,6 +24,8 @@ import com.facebook.ktfmt.kdoc.Escaping import com.facebook.ktfmt.kdoc.KDocCommentsHelper import com.google.common.collect.ImmutableList import com.google.common.collect.Range +import com.google.common.collect.RangeSet +import com.google.common.collect.TreeRangeSet import com.google.googlejavaformat.Doc import com.google.googlejavaformat.DocBuilder import com.google.googlejavaformat.Newlines @@ -86,11 +88,26 @@ object Formatter { format(META_FORMAT.copy(removeUnusedImports = removeUnusedImports), code) /** - * format formats the Kotlin code given in 'code' with the 'maxWidth' and returns it as a string. + * Formats the Kotlin code given in [code] and returns it as a string. + * + * @param lineRanges zero-indexed line ranges to format, using closed-open bounds, or null to + * format all code + * @param characterRanges zero-indexed character ranges to format, using closed-open bounds, or + * null to use only [lineRanges] + * + * When [lineRanges] or [characterRanges] are non-null, only pretty-print replacements are limited + * to those ranges. Whole-file cleanup passes, such as import cleanup and multiline string + * formatting, still run afterward, mirroring google-java-format's cleanup-after-selection behavior. */ @JvmStatic + @JvmOverloads @Throws(FormatterException::class, ParseError::class) - fun format(options: FormattingOptions, code: String): String { + fun format( + options: FormattingOptions, + code: String, + lineRanges: RangeSet? = null, + characterRanges: RangeSet? = null, + ): String { val (shebang, kotlinCode) = if (code.startsWith("#!")) { code.split("\n".toRegex(), limit = 2) @@ -99,20 +116,60 @@ object Formatter { } checkEscapeSequences(kotlinCode) - return FormatterContext(convertLineSeparators(kotlinCode)) - .transform { sortedAndDistinctImports(it) } - .transform { dropRedundantElements(it, options) } - .transform { addRedundantElements(it, options) } - .transform { prettyPrint(it, options, lineSeparator = "\n") } - .transform { addRedundantElements(it, options) } - .transform { MultilineStringFormatter(options.continuationIndent).format(it) } - .code + val normalizedKotlinCode = convertLineSeparators(kotlinCode) + val formattedCode = + if (lineRanges == null && characterRanges == null) { + FormatterContext(normalizedKotlinCode) + .transform { sortedAndDistinctImports(it) } + .transform { dropRedundantElements(it, options) } + .transform { addRedundantElements(it, options) } + .transform { prettyPrint(it, options, lineSeparator = "\n") } + .transform { addRedundantElements(it, options) } + .transform { MultilineStringFormatter(options.continuationIndent).format(it) } + .code + } else { + val selectedCharacterRanges = + characterRangesForPartialFormatting( + normalizedKotlinCode, + lineRanges, + characterRanges, + shebang, + ) + val partiallyFormattedCode = + if (selectedCharacterRanges.isEmpty) { + normalizedKotlinCode + } else { + FormatterContext(normalizedKotlinCode) + .transform { + prettyPrint( + it, + options, + lineSeparator = "\n", + characterRanges = selectedCharacterRanges.asRanges(), + ) + } + .code + } + FormatterContext(partiallyFormattedCode) + .transform { sortedAndDistinctImports(it) } + .transform { dropRedundantElements(it, options) } + .transform { addRedundantElements(it, options) } + .transform { MultilineStringFormatter(options.continuationIndent).format(it) } + .code + } + + return formattedCode .let { convertLineSeparators(it, checkNotNull(Newlines.guessLineSeparator(kotlinCode))) } .let { if (shebang.isEmpty()) it else shebang + "\n" + it } } /** prettyPrint reflows 'code' using google-java-format's engine. */ - private fun prettyPrint(file: KtFile, options: FormattingOptions, lineSeparator: String): String { + private fun prettyPrint( + file: KtFile, + options: FormattingOptions, + lineSeparator: String, + characterRanges: Collection> = ImmutableList.of(Range.closedOpen(0, file.text.length)), + ): String { val code = file.text val kotlinInput = KotlinInput(code, file) val javaOutput = @@ -130,13 +187,87 @@ object Formatter { doc.write(javaOutput) javaOutput.flush() - val tokenRangeSet = - kotlinInput.characterRangesToTokenRanges(ImmutableList.of(Range.closedOpen(0, code.length))) + val tokenRangeSet = kotlinInput.characterRangesToTokenRanges(characterRanges) return WhitespaceTombstones.replaceTombstoneWithTrailingWhitespace( JavaOutput.applyReplacements(code, javaOutput.getFormatReplacements(tokenRangeSet)) ) } + /** Converts zero-indexed, closed-open line ranges to character ranges in [input]. */ + private fun lineRangesToCharRanges(input: String, lineRanges: RangeSet): RangeSet { + val lineOffsets = mutableListOf() + val lineOffsetIterator = Newlines.lineOffsetIterator(input) + while (lineOffsetIterator.hasNext()) { + lineOffsets.add(lineOffsetIterator.next()) + } + lineOffsets.add(input.length + 1) + + val characterRanges = TreeRangeSet.create() + for (lineRange in + lineRanges.subRangeSet(Range.closedOpen(0, lineOffsets.size - 1)).asRanges()) { + val lineStart = lineOffsets[lineRange.lowerEndpoint()] + val lineEnd = lineOffsets[lineRange.upperEndpoint()] - 1 + val characterRange = Range.closedOpen(lineStart, lineEnd) + if (!characterRange.isEmpty) { + characterRanges.add(characterRange) + } + } + return characterRanges + } + + private fun characterRangesForPartialFormatting( + code: String, + lineRanges: RangeSet?, + characterRanges: RangeSet?, + shebang: String, + ): RangeSet { + val selectedCharacterRanges = TreeRangeSet.create() + if (lineRanges != null) { + val adjustedLineRanges = adjustLineRangesForShebang(lineRanges, shebang.isNotEmpty()) + selectedCharacterRanges.addAll(lineRangesToCharRanges(code, adjustedLineRanges)) + } + if (characterRanges != null) { + selectedCharacterRanges.addAll(adjustCharacterRangesForShebang(characterRanges, shebang)) + } + return selectedCharacterRanges + } + + private fun adjustLineRangesForShebang( + lineRanges: RangeSet, + hasShebang: Boolean, + ): RangeSet { + if (!hasShebang) { + return lineRanges + } + + val adjusted = TreeRangeSet.create() + for (lineRange in lineRanges.subRangeSet(Range.atLeast(1)).asRanges()) { + adjusted.add(Range.closedOpen(lineRange.lowerEndpoint() - 1, lineRange.upperEndpoint() - 1)) + } + return adjusted + } + + private fun adjustCharacterRangesForShebang( + characterRanges: RangeSet, + shebang: String, + ): RangeSet { + if (shebang.isEmpty()) { + return characterRanges + } + + val adjusted = TreeRangeSet.create() + val kotlinCodeStart = shebang.length + 1 + for (characterRange in characterRanges.subRangeSet(Range.atLeast(kotlinCodeStart)).asRanges()) { + adjusted.add( + Range.closedOpen( + characterRange.lowerEndpoint() - kotlinCodeStart, + characterRange.upperEndpoint() - kotlinCodeStart, + ) + ) + } + return adjusted + } + private fun createAstVisitor(options: FormattingOptions, builder: OpsBuilder): PsiElementVisitor { if (KotlinVersion.CURRENT < MINIMUM_KOTLIN_VERSION) { throw RuntimeException("Unsupported runtime Kotlin version: " + KotlinVersion.CURRENT) From 2d15471f656fb9c4219a92a5440147ce5da4708b Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 00:34:35 -0400 Subject: [PATCH 04/17] Wire up in Main --- core/src/main/java/com/facebook/ktfmt/cli/Main.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/facebook/ktfmt/cli/Main.kt b/core/src/main/java/com/facebook/ktfmt/cli/Main.kt index f026772b..a7c4c4e0 100644 --- a/core/src/main/java/com/facebook/ktfmt/cli/Main.kt +++ b/core/src/main/java/com/facebook/ktfmt/cli/Main.kt @@ -116,6 +116,11 @@ class Main( return EXIT_CODE_FAILURE } + if (parsedArgs.isPartialFormat() && files.size != 1) { + err.println("partial formatting is only supported for a single file") + return EXIT_CODE_FAILURE + } + val returnCode = AtomicInteger(EXIT_CODE_SUCCESS) files.parallelStream().forEach { try { @@ -146,7 +151,12 @@ class Main( else EditorConfigResolver.resolveFormattingOptions(file, args.formattingOptions) val bytes = if (file == null) input else FileInputStream(file) val code = BufferedReader(InputStreamReader(bytes, UTF_8)).readText().removePrefix(UTF8_BOM) - val formattedCode = Formatter.format(formattingOptions, code) + val formattedCode = + if (!args.isPartialFormat()) { + Formatter.format(formattingOptions, code) + } else { + Formatter.format(formattingOptions, code, args.lineRanges, args.characterRanges) + } val alreadyFormatted = code == formattedCode // stdin @@ -190,4 +200,7 @@ class Main( throw e } } + + private fun ParsedArgs.isPartialFormat(): Boolean = + !lineRanges.isEmpty || !characterRanges.isEmpty } From b18ae12296977b4d3fc2f68b6c2c45c5dd8537ea Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 00:37:32 -0400 Subject: [PATCH 05/17] Main tests --- .../java/com/facebook/ktfmt/cli/MainTest.kt | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) diff --git a/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt index c09aba5e..5b97442c 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt @@ -561,4 +561,257 @@ class MainTest { assertThat(returnValue).isEqualTo(1) assertThat(err.toString(testCharset)).contains("foo.kt:1:14: error: ") } + + @Test + fun `--lines does not format file lines before the selection`() { + val code = + """ + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | val adjacent = 3 + |} + |""" + .trimMargin() + val file = root.resolve("foo.kt") + file.writeText(code, UTF_8) + + val exitCode = + Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf("--lines=4", file.toString())) + .run() + + assertThat(exitCode).isEqualTo(0) + assertThat(file.readText(UTF_8)) + .isEqualTo( + """ + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | val adjacent = 3 + |} + |""" + .trimMargin() + ) + } + + @Test + fun `--lines does not format stdin lines before the selection`() { + val code = + """ + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | val adjacent = 3 + |} + |""" + .trimMargin() + + val exitCode = + Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=4", "-")) + .run() + + assertThat(exitCode).isEqualTo(0) + assertThat(out.toString(UTF_8)) + .isEqualTo( + """ + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | val adjacent = 3 + |} + |""" + .trimMargin() + ) + } + + @Test + fun `--lines applies import cleanup after selected formatting`() { + val code = + """ + |import com.unused.Sample + |import com.used.FooBarBaz as Baz + |import com.used.bar + | + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | Baz(bar) + |} + |""" + .trimMargin() + + val exitCode = + Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=8", "-")) + .run() + + assertThat(exitCode).isEqualTo(0) + assertThat(out.toString(UTF_8)) + .isEqualTo( + """ + |import com.used.FooBarBaz as Baz + |import com.used.bar + | + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | Baz(bar) + |} + |""" + .trimMargin() + ) + } + + @Test + fun `--lines applies multiline string cleanup after selected formatting`() { + val code = + """ + |val indent = + | ""${'"'} + | example + | of + | a + | + | multiline + | string + | ""${'"'} + | .trimIndent() + | + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + |} + |""" + .trimMargin() + + val exitCode = + Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=15", "-")) + .run() + + assertThat(exitCode).isEqualTo(0) + assertThat(out.toString(UTF_8)) + .isEqualTo( + """ + |val indent = + | ""${'"'} + | example + | of + | a + | + | multiline + | string + | ""${'"'} + | .trimIndent() + | + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + |} + |""" + .trimMargin() + ) + } + + @Test + fun `--offset and --length format the selected file cursor line`() { + val code = + """ + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | val adjacent = 3 + |} + |""" + .trimMargin() + val file = root.resolve("foo.kt") + file.writeText(code, UTF_8) + + val exitCode = + Main( + emptyInput, + PrintStream(out), + PrintStream(err), + arrayOf( + "--offset=${code.indexOf("selected")}", + "--length=0", + file.toString(), + ), + ) + .run() + + assertThat(exitCode).isEqualTo(0) + assertThat(file.readText(UTF_8)) + .isEqualTo( + """ + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | val adjacent = 3 + |} + |""" + .trimMargin() + ) + } + + @Test + fun `--offset and --length format the selected stdin cursor line`() { + val code = + """ + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | val adjacent = 3 + |} + |""" + .trimMargin() + + val exitCode = + Main( + code.byteInputStream(), + PrintStream(out), + PrintStream(err), + arrayOf("--offset=${code.indexOf("selected")}", "--length=0", "-"), + ) + .run() + + assertThat(exitCode).isEqualTo(0) + assertThat(out.toString(UTF_8)) + .isEqualTo( + """ + |fun untouched ( ) = 1 + | + |fun test() { + | val selected = 2 + | val adjacent = 3 + |} + |""" + .trimMargin() + ) + } + + @Test + fun `--lines rejects directories that expand to multiple files`() { + val dir = root.resolve("dir") + dir.mkdirs() + dir.resolve("foo.kt").writeText("fun foo () = 1", UTF_8) + dir.resolve("bar.kt").writeText("fun bar () = 1", UTF_8) + + val exitCode = + Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf("--lines=1", dir.toString())) + .run() + + assertThat(exitCode).isEqualTo(1) + assertThat(err.toString(testCharset)) + .contains("partial formatting is only supported for a single file") + } } From d531ff5165ad5b60577ca278f390c7ebe947cb4a Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 00:43:43 -0400 Subject: [PATCH 06/17] Docs and changelog --- CHANGELOG.md | 7 +++++++ README.md | 10 ++++++++++ .../src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt | 4 ++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 075ad6ef..08332b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ## [Unreleased] +### Added + +- Support partial formatting with `--lines`/`--line` and matching + `--offset`/`--length` pairs, mirroring + [google-java-format's](https://github.com/google/google-java-format#from-the-command-line) + selected-range formatting flags. + ### Changed * Reduced overall number of allocations to improve formatting performance (~6-7%) (https://github.com/facebook/ktfmt/pull/620) diff --git a/README.md b/README.md index c6f26802..91a42c60 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,16 @@ $ brew install ktfmt $ java -jar /path/to/ktfmt--with-dependencies.jar [--kotlinlang-style | --google-style] [files...] ``` +The formatter can act on whole files, on limited lines (`--lines` or `--line`), or on specific +offsets (`--offset` and `--length`). Line ranges look like `5` or `1:12,14`, may be used multiple +times, and are 1-based. Offset ranges must provide matching `--offset` and `--length` pairs; +`--length=0` formats the whole line containing the given `--offset`. + +Partial formatting limits the core pretty-print replacements to the selected ranges. Whole-file +cleanup passes, such as import cleanup and multiline string formatting, still run afterward to match +[google-java-format's](https://github.com/google/google-java-format#from-the-command-line) +cleanup-after-selection behavior. + `--kotlinlang-style` makes `ktfmt` use a block indent of 4 spaces instead of 2. See below for details. diff --git a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt index c044c8c0..f9ded894 100644 --- a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt +++ b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt @@ -94,8 +94,8 @@ data class ParsedArgs( | --offset= Character offset to format, paired with --length. | May be used multiple times. | --length= Character length to format, paired with --offset. - | May be used multiple times. 0 formats the line - | under the cursor. + | May be used multiple times. 0 formats the whole + | line containing the given --offset. | --set-exit-if-changed Sets exit code to 1 if any input file was not | formatted/touched | --do-not-remove-unused-imports Leaves all imports in place, even if not used From f58892a2cac67cf3cf18a530029db84157b329de Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 00:48:44 -0400 Subject: [PATCH 07/17] Fix sortedAndDistinctImports order --- core/src/main/java/com/facebook/ktfmt/format/Formatter.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt b/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt index f63dfc19..55646aa7 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt @@ -97,7 +97,8 @@ object Formatter { * * When [lineRanges] or [characterRanges] are non-null, only pretty-print replacements are limited * to those ranges. Whole-file cleanup passes, such as import cleanup and multiline string - * formatting, still run afterward, mirroring google-java-format's cleanup-after-selection behavior. + * formatting, still run afterward, mirroring google-java-format's cleanup-after-selection + * behavior. */ @JvmStatic @JvmOverloads @@ -151,8 +152,8 @@ object Formatter { .code } FormatterContext(partiallyFormattedCode) - .transform { sortedAndDistinctImports(it) } .transform { dropRedundantElements(it, options) } + .transform { sortedAndDistinctImports(it) } .transform { addRedundantElements(it, options) } .transform { MultilineStringFormatter(options.continuationIndent).format(it) } .code From 07ee0a91cc965d61238dfe93291bab75a2562977 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 00:48:58 -0400 Subject: [PATCH 08/17] Format --- .../java/com/facebook/ktfmt/cli/MainTest.kt | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt index 5b97442c..2849b78d 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt @@ -565,7 +565,7 @@ class MainTest { @Test fun `--lines does not format file lines before the selection`() { val code = - """ + """ |fun untouched ( ) = 1 | |fun test() { @@ -573,18 +573,18 @@ class MainTest { | val adjacent = 3 |} |""" - .trimMargin() + .trimMargin() val file = root.resolve("foo.kt") file.writeText(code, UTF_8) val exitCode = - Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf("--lines=4", file.toString())) - .run() + Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf("--lines=4", file.toString())) + .run() assertThat(exitCode).isEqualTo(0) assertThat(file.readText(UTF_8)) - .isEqualTo( - """ + .isEqualTo( + """ |fun untouched ( ) = 1 | |fun test() { @@ -592,14 +592,14 @@ class MainTest { | val adjacent = 3 |} |""" - .trimMargin() - ) + .trimMargin() + ) } @Test fun `--lines does not format stdin lines before the selection`() { val code = - """ + """ |fun untouched ( ) = 1 | |fun test() { @@ -607,16 +607,16 @@ class MainTest { | val adjacent = 3 |} |""" - .trimMargin() + .trimMargin() val exitCode = - Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=4", "-")) - .run() + Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=4", "-")) + .run() assertThat(exitCode).isEqualTo(0) assertThat(out.toString(UTF_8)) - .isEqualTo( - """ + .isEqualTo( + """ |fun untouched ( ) = 1 | |fun test() { @@ -624,14 +624,14 @@ class MainTest { | val adjacent = 3 |} |""" - .trimMargin() - ) + .trimMargin() + ) } @Test fun `--lines applies import cleanup after selected formatting`() { val code = - """ + """ |import com.unused.Sample |import com.used.FooBarBaz as Baz |import com.used.bar @@ -643,16 +643,16 @@ class MainTest { | Baz(bar) |} |""" - .trimMargin() + .trimMargin() val exitCode = - Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=8", "-")) - .run() + Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=8", "-")) + .run() assertThat(exitCode).isEqualTo(0) assertThat(out.toString(UTF_8)) - .isEqualTo( - """ + .isEqualTo( + """ |import com.used.FooBarBaz as Baz |import com.used.bar | @@ -663,14 +663,14 @@ class MainTest { | Baz(bar) |} |""" - .trimMargin() - ) + .trimMargin() + ) } @Test fun `--lines applies multiline string cleanup after selected formatting`() { val code = - """ + """ |val indent = | ""${'"'} | example @@ -688,16 +688,16 @@ class MainTest { | val selected = 2 |} |""" - .trimMargin() + .trimMargin() val exitCode = - Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=15", "-")) - .run() + Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=15", "-")) + .run() assertThat(exitCode).isEqualTo(0) assertThat(out.toString(UTF_8)) - .isEqualTo( - """ + .isEqualTo( + """ |val indent = | ""${'"'} | example @@ -715,14 +715,14 @@ class MainTest { | val selected = 2 |} |""" - .trimMargin() - ) + .trimMargin() + ) } @Test fun `--offset and --length format the selected file cursor line`() { val code = - """ + """ |fun untouched ( ) = 1 | |fun test() { @@ -730,27 +730,27 @@ class MainTest { | val adjacent = 3 |} |""" - .trimMargin() + .trimMargin() val file = root.resolve("foo.kt") file.writeText(code, UTF_8) val exitCode = - Main( - emptyInput, - PrintStream(out), - PrintStream(err), - arrayOf( - "--offset=${code.indexOf("selected")}", - "--length=0", - file.toString(), - ), - ) - .run() + Main( + emptyInput, + PrintStream(out), + PrintStream(err), + arrayOf( + "--offset=${code.indexOf("selected")}", + "--length=0", + file.toString(), + ), + ) + .run() assertThat(exitCode).isEqualTo(0) assertThat(file.readText(UTF_8)) - .isEqualTo( - """ + .isEqualTo( + """ |fun untouched ( ) = 1 | |fun test() { @@ -758,14 +758,14 @@ class MainTest { | val adjacent = 3 |} |""" - .trimMargin() - ) + .trimMargin() + ) } @Test fun `--offset and --length format the selected stdin cursor line`() { val code = - """ + """ |fun untouched ( ) = 1 | |fun test() { @@ -773,21 +773,21 @@ class MainTest { | val adjacent = 3 |} |""" - .trimMargin() + .trimMargin() val exitCode = - Main( - code.byteInputStream(), - PrintStream(out), - PrintStream(err), - arrayOf("--offset=${code.indexOf("selected")}", "--length=0", "-"), - ) - .run() + Main( + code.byteInputStream(), + PrintStream(out), + PrintStream(err), + arrayOf("--offset=${code.indexOf("selected")}", "--length=0", "-"), + ) + .run() assertThat(exitCode).isEqualTo(0) assertThat(out.toString(UTF_8)) - .isEqualTo( - """ + .isEqualTo( + """ |fun untouched ( ) = 1 | |fun test() { @@ -795,8 +795,8 @@ class MainTest { | val adjacent = 3 |} |""" - .trimMargin() - ) + .trimMargin() + ) } @Test @@ -807,11 +807,11 @@ class MainTest { dir.resolve("bar.kt").writeText("fun bar () = 1", UTF_8) val exitCode = - Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf("--lines=1", dir.toString())) - .run() + Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf("--lines=1", dir.toString())) + .run() assertThat(exitCode).isEqualTo(1) assertThat(err.toString(testCharset)) - .contains("partial formatting is only supported for a single file") + .contains("partial formatting is only supported for a single file") } } From b6457a220763a7612d97a54c86201d1a0e4c3974 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 00:52:11 -0400 Subject: [PATCH 09/17] Support in the IDE --- CHANGELOG.md | 1 + .../ktfmt/intellij/KtfmtFormattingService.kt | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08332b43..6edad6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). `--offset`/`--length` pairs, mirroring [google-java-format's](https://github.com/google/google-java-format#from-the-command-line) selected-range formatting flags. +- Support selected-range formatting in the IntelliJ plugin. ### Changed diff --git a/ktfmt_idea_plugin/src/main/kotlin/com/facebook/ktfmt/intellij/KtfmtFormattingService.kt b/ktfmt_idea_plugin/src/main/kotlin/com/facebook/ktfmt/intellij/KtfmtFormattingService.kt index 4dcb988f..57722b70 100644 --- a/ktfmt_idea_plugin/src/main/kotlin/com/facebook/ktfmt/intellij/KtfmtFormattingService.kt +++ b/ktfmt_idea_plugin/src/main/kotlin/com/facebook/ktfmt/intellij/KtfmtFormattingService.kt @@ -18,10 +18,14 @@ package com.facebook.ktfmt.intellij import com.facebook.ktfmt.format.Formatter.format import com.facebook.ktfmt.format.FormattingOptions +import com.google.common.collect.Range +import com.google.common.collect.RangeSet +import com.google.common.collect.TreeRangeSet import com.google.googlejavaformat.java.FormatterException import com.intellij.formatting.service.AsyncDocumentFormattingService import com.intellij.formatting.service.AsyncFormattingRequest import com.intellij.formatting.service.FormattingService.Feature +import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiFile import org.jetbrains.kotlin.idea.KotlinFileType @@ -47,7 +51,7 @@ class KtfmtFormattingService : AsyncDocumentFormattingService() { override fun getName(): String = "ktfmt" - override fun getFeatures(): Set = emptySet() + override fun getFeatures(): Set = setOf(Feature.FORMAT_FRAGMENTS) override fun canFormat(file: PsiFile): Boolean = KotlinFileType.INSTANCE.name == file.fileType.name && @@ -59,7 +63,12 @@ class KtfmtFormattingService : AsyncDocumentFormattingService() { ) : FormattingTask { override fun run() { try { - val formattedText = format(formattingOptions, request.documentText) + val formattedText = + if (request.isWholeFileFormatting()) { + format(formattingOptions, request.documentText) + } else { + format(formattingOptions, request.documentText, characterRanges = request.toRanges()) + } request.onTextReady(formattedText) } catch (e: FormatterException) { request.onError( @@ -72,5 +81,24 @@ class KtfmtFormattingService : AsyncDocumentFormattingService() { override fun isRunUnderProgress(): Boolean = true override fun cancel(): Boolean = false + + private fun AsyncFormattingRequest.toRanges(): RangeSet { + val ranges = TreeRangeSet.create() + for (range in formattingRanges) { + ranges.add(Range.closedOpen(range.startOffset, range.endOffset)) + } + return ranges + } + + private fun AsyncFormattingRequest.isWholeFileFormatting(): Boolean { + val ranges = formattingRanges + return ranges.size == 1 && ranges.single().isWholeFileRange(documentText.length) + } + + private fun TextRange.isWholeFileRange(documentLength: Int): Boolean { + // IntelliJ represents a no-selection whole-file format request as a range covering the file. + // Match GJF's leniency for end offsets because the IDE can pass slightly inaccurate ranges. + return startOffset == 0 && endOffset >= documentLength + } } } From 1e4c85f3be5a357ed1142064be80a75ebbcabe2a Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Sat, 13 Jun 2026 01:03:57 -0400 Subject: [PATCH 10/17] Fixup statement markers + support properties --- .../ktfmt/format/KotlinInputAstVisitor.kt | 6 ++- .../java/com/facebook/ktfmt/cli/MainTest.kt | 40 ++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 280b73ea..43d9b851 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -436,9 +436,9 @@ class KotlinInputAstVisitor( builder.blankLineWanted(OpsBuilder.BlankLineWanted.PRESERVE) } first = false - builder.markForPartialFormat() + markForPartialFormat() visitStatement(statement) - builder.markForPartialFormat() + markForPartialFormat() } } @@ -2146,7 +2146,9 @@ class KotlinInputAstVisitor( } builder.blankLineWanted(blankLineBetweenMembers) + markForPartialFormat() builder.block(ZERO) { visit(curr) } + markForPartialFormat() builder.guessToken(";") builder.forcedBreak() diff --git a/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt index 2849b78d..a41b3fe6 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt @@ -563,7 +563,7 @@ class MainTest { } @Test - fun `--lines does not format file lines before the selection`() { + fun `--lines formats the selected file statement`() { val code = """ |fun untouched ( ) = 1 @@ -597,7 +597,7 @@ class MainTest { } @Test - fun `--lines does not format stdin lines before the selection`() { + fun `--lines formats the selected stdin statement`() { val code = """ |fun untouched ( ) = 1 @@ -628,6 +628,42 @@ class MainTest { ) } + @Test + fun `--lines formats the selected class member statement`() { + val code = + """ + |class Sample { + | fun untouched ( ) = 1 + | + | fun test() { + | val selected = 2 + | val adjacent = 3 + | } + |} + |""" + .trimMargin() + + val exitCode = + Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--lines=5", "-")) + .run() + + assertThat(exitCode).isEqualTo(0) + assertThat(out.toString(UTF_8)) + .isEqualTo( + """ + |class Sample { + | fun untouched ( ) = 1 + | + | fun test() { + | val selected = 2 + | val adjacent = 3 + | } + |} + |""" + .trimMargin() + ) + } + @Test fun `--lines applies import cleanup after selected formatting`() { val code = From 2440b6e2743f5cd73193e75f04c4bb1f6fa3f1da Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 22 Jun 2026 00:10:53 -0400 Subject: [PATCH 11/17] Fix partial import cleanup after rebase --- .../com/facebook/ktfmt/format/Formatter.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt b/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt index 55646aa7..6382d555 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt @@ -153,7 +153,7 @@ object Formatter { } FormatterContext(partiallyFormattedCode) .transform { dropRedundantElements(it, options) } - .transform { sortedAndDistinctImports(it) } + .transform { sortedAndDistinctImports(it, trimLeadingWhitespace = true) } .transform { addRedundantElements(it, options) } .transform { MultilineStringFormatter(options.continuationIndent).format(it) } .code @@ -169,7 +169,8 @@ object Formatter { file: KtFile, options: FormattingOptions, lineSeparator: String, - characterRanges: Collection> = ImmutableList.of(Range.closedOpen(0, file.text.length)), + characterRanges: Collection> = + ImmutableList.of(Range.closedOpen(0, file.text.length)), ): String { val code = file.text val kotlinInput = KotlinInput(code, file) @@ -290,7 +291,10 @@ object Formatter { } } - private fun sortedAndDistinctImports(file: KtFile): String { + private fun sortedAndDistinctImports( + file: KtFile, + trimLeadingWhitespace: Boolean = false, + ): String { val code = file.text val importList = file.importList ?: return code @@ -333,8 +337,14 @@ object Formatter { * which is acceptable -- later prettyPrint step will fix that) and avoid extra-append when it is redundant. */ val needsTerminator = body.lastIndexOf('\n').let { it >= 0 && body.indexOf("//", it + 1) >= 0 } + val replaceStart = + if (trimLeadingWhitespace && code.substring(0, importList.startOffset).isBlank()) { + 0 + } else { + importList.startOffset + } return code.replaceRange( - importList.startOffset, + replaceStart, importList.endOffset, if (needsTerminator) body + "\n" else body, ) From 9a322171763ce08313bbb865d43baf5c0729dd11 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 22 Jun 2026 00:27:46 -0400 Subject: [PATCH 12/17] Use toIntOrNull() --- .../main/java/com/facebook/ktfmt/cli/ParsedArgs.kt | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt index f9ded894..8efb5141 100644 --- a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt +++ b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt @@ -190,7 +190,7 @@ data class ParsedArgs( arg.startsWith("--offset=") -> parseKeyValueArg("--offset", arg).let { value -> offsets.add( - value.toIntOrNull() ?: return ParseResult.Error(invalidInt("--offset", value)) + value?.toIntOrNull() ?: return ParseResult.Error(invalidInt("--offset", value)) ) } arg == "--length" -> { @@ -201,7 +201,7 @@ data class ParsedArgs( arg.startsWith("--length=") -> parseKeyValueArg("--length", arg).let { value -> lengths.add( - value.toIntOrNull() ?: return ParseResult.Error(invalidInt("--length", value)) + value?.toIntOrNull() ?: return ParseResult.Error(invalidInt("--length", value)) ) } arg.startsWith("--") -> return ParseResult.Error("Unexpected option: $arg") @@ -258,14 +258,6 @@ data class ParsedArgs( return parts[1].takeIf { parts[0] == key || parts.size == 2 } } - private fun String?.toIntOrNull(): Int? { - return try { - this?.toInt() - } catch (_: NumberFormatException) { - null - } - } - private fun invalidInt(flag: String, value: String?): String = "invalid integer value for $flag: $value" From bfb7ff26673231cc635ffa0b4c064d4499cafac9 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 22 Jun 2026 00:31:12 -0400 Subject: [PATCH 13/17] Split up logic --- .../java/com/facebook/ktfmt/cli/ParsedArgs.kt | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt index 8efb5141..5a105c74 100644 --- a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt +++ b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt @@ -164,45 +164,53 @@ data class ParsedArgs( ?: return ParseResult.Error( "Found option '${arg}', expected '${"--stdin-name"}='" ) - arg == "--lines" || arg == "--line" -> { + arg.startsWith("--line") -> { + val argSplit = arg.split('=', limit = 2) + val key = argSplit.first() + if (key != "--lines" && key != "--line") { + return ParseResult.Error("Unexpected option: $key") + } val value = - nextValue() ?: return ParseResult.Error("required value was not provided for: $arg") + if (argSplit.size > 1) { + argSplit.last() + } else { + nextValue() + ?: return ParseResult.Error("required value was not provided for: $key") + } when (val result = parseLineRanges(lineRanges, value)) { LineRangeParseResult.Success -> Unit is LineRangeParseResult.Error -> return ParseResult.Error(result.message) } } - arg.startsWith("--lines=") -> - when (val result = parseLineRanges(lineRanges, parseKeyValueArg("--lines", arg))) { - LineRangeParseResult.Success -> Unit - is LineRangeParseResult.Error -> return ParseResult.Error(result.message) - } - arg.startsWith("--line=") -> - when (val result = parseLineRanges(lineRanges, parseKeyValueArg("--line", arg))) { - LineRangeParseResult.Success -> Unit - is LineRangeParseResult.Error -> return ParseResult.Error(result.message) + arg.startsWith("--offset") -> + arg.split('=', limit = 2).let { argSplit -> + val key = argSplit.first() + if (key != "--offset") { + return ParseResult.Error("Unexpected option: $key") + } + val value = + if (argSplit.size > 1) { + argSplit.last() + } else { + nextValue() + ?: return ParseResult.Error("required value was not provided for: $key") + } + offsets.add(value.toIntOrNull() ?: return ParseResult.Error(invalidInt(key, value))) } - arg == "--offset" -> { - val value = - nextValue() ?: return ParseResult.Error("required value was not provided for: $arg") - offsets.add(value.toIntOrNull() ?: return ParseResult.Error(invalidInt(arg, value))) - } - arg.startsWith("--offset=") -> - parseKeyValueArg("--offset", arg).let { value -> - offsets.add( - value?.toIntOrNull() ?: return ParseResult.Error(invalidInt("--offset", value)) - ) - } - arg == "--length" -> { - val value = - nextValue() ?: return ParseResult.Error("required value was not provided for: $arg") - lengths.add(value.toIntOrNull() ?: return ParseResult.Error(invalidInt(arg, value))) - } - arg.startsWith("--length=") -> - parseKeyValueArg("--length", arg).let { value -> - lengths.add( - value?.toIntOrNull() ?: return ParseResult.Error(invalidInt("--length", value)) - ) + arg.startsWith("--length") -> + arg.split('=', limit = 2).let { argSplit -> + val key = argSplit.first() + if (key != "--length") { + return ParseResult.Error("Unexpected option: $key") + } + val value = + if (argSplit.size > 1) { + argSplit.last() + } else { + nextValue() + ?: return ParseResult.Error("required value was not provided for: $key") + } + lengths.add(value.toIntOrNull() ?: return ParseResult.Error(invalidInt(key, value))) } arg.startsWith("--") -> return ParseResult.Error("Unexpected option: $arg") arg.startsWith("@") -> return ParseResult.Error("Unexpected option: $arg") From a7589d06e37e1d7d23cdc7052ae407d223307382 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 22 Jun 2026 00:33:08 -0400 Subject: [PATCH 14/17] Clean up invalidInt() helper --- .../main/java/com/facebook/ktfmt/cli/ParsedArgs.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt index 5a105c74..ccab9d7e 100644 --- a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt +++ b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt @@ -195,7 +195,10 @@ data class ParsedArgs( nextValue() ?: return ParseResult.Error("required value was not provided for: $key") } - offsets.add(value.toIntOrNull() ?: return ParseResult.Error(invalidInt(key, value))) + offsets.add( + value.toIntOrNull() + ?: return ParseResult.Error("invalid integer value for $key: $value") + ) } arg.startsWith("--length") -> arg.split('=', limit = 2).let { argSplit -> @@ -210,7 +213,10 @@ data class ParsedArgs( nextValue() ?: return ParseResult.Error("required value was not provided for: $key") } - lengths.add(value.toIntOrNull() ?: return ParseResult.Error(invalidInt(key, value))) + lengths.add( + value.toIntOrNull() + ?: return ParseResult.Error("invalid integer value for $key: $value") + ) } arg.startsWith("--") -> return ParseResult.Error("Unexpected option: $arg") arg.startsWith("@") -> return ParseResult.Error("Unexpected option: $arg") @@ -266,9 +272,6 @@ data class ParsedArgs( return parts[1].takeIf { parts[0] == key || parts.size == 2 } } - private fun invalidInt(flag: String, value: String?): String = - "invalid integer value for $flag: $value" - private fun parseLineRanges( lineRanges: RangeSet, lineRangesArg: String?, From becd1ac7eac000c0630ad23572d61670f138ec83 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 22 Jun 2026 00:35:27 -0400 Subject: [PATCH 15/17] Add a parseOptions helper --- .../com/facebook/ktfmt/cli/ParsedArgsTest.kt | 96 ++++++++++--------- 1 file changed, 50 insertions(+), 46 deletions(-) diff --git a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt index 748381d9..227b9a2e 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt @@ -43,19 +43,19 @@ class ParsedArgsTest { @Test fun `unknown flags return an error`() { - val result = ParsedArgs.parseOptions(arrayOf("--unknown")) + val result = parseOptions("--unknown") assertThat(result).isInstanceOf(ParseResult.Error::class.java) } @Test fun `unknown flags starting with '@' return an error`() { - val result = ParsedArgs.parseOptions(arrayOf("@unknown")) + val result = parseOptions("@unknown") assertThat(result).isInstanceOf(ParseResult.Error::class.java) } @Test fun `parseOptions uses default values when args are empty`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("foo.kt"))) + val parsed = assertSucceeds(parseOptions("foo.kt")) val formattingOptions = parsed.formattingOptions @@ -65,69 +65,67 @@ class ParsedArgsTest { @Test fun `parseOptions recognizes --meta-style`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--meta-style", "foo.kt"))) + val parsed = assertSucceeds(parseOptions("--meta-style", "foo.kt")) assertThat(parsed.formattingOptions).isEqualTo(Formatter.META_FORMAT) } @Test fun `parseOptions recognizes --google-style`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--google-style", "foo.kt"))) + val parsed = assertSucceeds(parseOptions("--google-style", "foo.kt")) assertThat(parsed.formattingOptions).isEqualTo(Formatter.GOOGLE_FORMAT) } @Test fun `parseOptions recognizes --dry-run`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--dry-run", "foo.kt"))) + val parsed = assertSucceeds(parseOptions("--dry-run", "foo.kt")) assertThat(parsed.dryRun).isTrue() } @Test fun `parseOptions recognizes -n as --dry-run`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("-n", "foo.kt"))) + val parsed = assertSucceeds(parseOptions("-n", "foo.kt")) assertThat(parsed.dryRun).isTrue() } @Test fun `parseOptions recognizes --set-exit-if-changed`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--set-exit-if-changed", "foo.kt"))) + val parsed = assertSucceeds(parseOptions("--set-exit-if-changed", "foo.kt")) assertThat(parsed.setExitIfChanged).isTrue() } @Test fun `parseOptions defaults to removing imports`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("foo.kt"))) + val parsed = assertSucceeds(parseOptions("foo.kt")) assertThat(parsed.formattingOptions.removeUnusedImports).isTrue() } @Test fun `parseOptions recognizes --do-not-remove-unused-imports to removing imports`() { - val parsed = - assertSucceeds(ParsedArgs.parseOptions(arrayOf("--do-not-remove-unused-imports", "foo.kt"))) + val parsed = assertSucceeds(parseOptions("--do-not-remove-unused-imports", "foo.kt")) assertThat(parsed.formattingOptions.removeUnusedImports).isFalse() } @Test fun `parseOptions recognizes --enable-editorconfig`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--enable-editorconfig", "foo.kt"))) + val parsed = assertSucceeds(parseOptions("--enable-editorconfig", "foo.kt")) assertThat(parsed.editorConfig).isEqualTo(true) } @Test fun `parseOptions recognizes --quiet`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--quiet", "foo.kt"))) + val parsed = assertSucceeds(parseOptions("--quiet", "foo.kt")) assertThat(parsed.quiet).isTrue() } @Test fun `parseOptions recognizes --stdin-name`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--stdin-name=my/foo.kt", "-"))) + val parsed = assertSucceeds(parseOptions("--stdin-name=my/foo.kt", "-")) assertThat(parsed.stdinName).isEqualTo("my/foo.kt") } @Test fun `parseOptions recognizes --lines ranges`() { - val parsed = - assertSucceeds(ParsedArgs.parseOptions(arrayOf("--lines=1:3,5", "--lines", "7", "foo.kt"))) + val parsed = assertSucceeds(parseOptions("--lines=1:3,5", "--lines", "7", "foo.kt")) assertThat(parsed.lineRanges) .isEqualTo( @@ -141,14 +139,14 @@ class ParsedArgsTest { @Test fun `parseOptions recognizes --line alias`() { - assertThat(ParsedArgs.parseOptions(arrayOf("--line=1", "foo.kt"))) + assertThat(parseOptions("--line=1", "foo.kt")) .isEqualTo( parseResultOk( fileNames = listOf("foo.kt"), lineRanges = lineRanges(Range.closedOpen(0, 1)), ) ) - assertThat(assertSucceeds(ParsedArgs.parseOptions(arrayOf("--line", "2", "foo.kt"))).lineRanges) + assertThat(assertSucceeds(parseOptions("--line", "2", "foo.kt")).lineRanges) .isEqualTo(lineRanges(Range.closedOpen(1, 2))) } @@ -156,8 +154,14 @@ class ParsedArgsTest { fun `parseOptions recognizes offset and length pairs`() { val parsed = assertSucceeds( - ParsedArgs.parseOptions( - arrayOf("--offset=10", "--length=5", "--offset", "20", "--length", "0", "foo.kt") + parseOptions( + "--offset=10", + "--length=5", + "--offset", + "20", + "--length", + "0", + "foo.kt", ) ) @@ -172,36 +176,35 @@ class ParsedArgsTest { @Test fun `parseOptions rejects --lines without value`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("--lines")) + val parseResult = parseOptions("--lines") assertThat(parseResult) .isEqualTo(ParseResult.Error("required value was not provided for: --lines")) } @Test fun `parseOptions rejects invalid --lines range`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("--lines=not-a-line", "foo.kt")) + val parseResult = parseOptions("--lines=not-a-line", "foo.kt") assertThat(parseResult) .isEqualTo(ParseResult.Error("invalid line range for --lines: not-a-line")) } @Test fun `parseOptions rejects --offset without value`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("--offset")) + val parseResult = parseOptions("--offset") assertThat(parseResult) .isEqualTo(ParseResult.Error("required value was not provided for: --offset")) } @Test fun `parseOptions rejects invalid --offset`() { - val parseResult = - ParsedArgs.parseOptions(arrayOf("--offset=not-an-offset", "--length=1", "foo.kt")) + val parseResult = parseOptions("--offset=not-an-offset", "--length=1", "foo.kt") assertThat(parseResult) .isEqualTo(ParseResult.Error("invalid integer value for --offset: not-an-offset")) } @Test fun `parseOptions rejects mismatched --offset and --length counts`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("--offset=1", "foo.kt")) + val parseResult = parseOptions("--offset=1", "foo.kt") assertThat(parseResult) .isEqualTo( ParseResult.Error("--offset and --length flags must be provided in matching pairs") @@ -210,78 +213,75 @@ class ParsedArgsTest { @Test fun `parseOptions rejects --lines with multiple files`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("--lines=1", "foo.kt", "bar.kt")) + val parseResult = parseOptions("--lines=1", "foo.kt", "bar.kt") assertThat(parseResult) .isEqualTo(ParseResult.Error("partial formatting is only supported for a single file")) } @Test fun `parseOptions rejects --offset with multiple files`() { - val parseResult = - ParsedArgs.parseOptions(arrayOf("--offset=1", "--length=1", "foo.kt", "bar.kt")) + val parseResult = parseOptions("--offset=1", "--length=1", "foo.kt", "bar.kt") assertThat(parseResult) .isEqualTo(ParseResult.Error("partial formatting is only supported for a single file")) } @Test fun `parseOptions accepts --stdin-name with empty value`() { - val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--stdin-name=", "-"))) + val parsed = assertSucceeds(parseOptions("--stdin-name=", "-")) assertThat(parsed.stdinName).isEqualTo("") } @Test fun `parseOptions rejects --stdin-name without value`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("--stdin-name")) + val parseResult = parseOptions("--stdin-name") assertThat(parseResult).isInstanceOf(ParseResult.Error::class.java) } @Test fun `parseOptions rejects '-' and files at the same time`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("-", "File.kt")) + val parseResult = parseOptions("-", "File.kt") assertThat(parseResult).isInstanceOf(ParseResult.Error::class.java) } @Test fun `parseOptions rejects --stdin-name when not reading from stdin`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("--stdin-name=foo", "file1.kt")) + val parseResult = parseOptions("--stdin-name=foo", "file1.kt") assertThat(parseResult).isInstanceOf(ParseResult.Error::class.java) } @Test fun `parseOptions recognises --help`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("--help")) + val parseResult = parseOptions("--help") assertThat(parseResult).isInstanceOf(ParseResult.ShowMessage::class.java) } @Test fun `parseOptions recognises -h`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("-h")) + val parseResult = parseOptions("-h") assertThat(parseResult).isInstanceOf(ParseResult.ShowMessage::class.java) } @Test fun `arg --help overrides all others`() { - val parseResult = - ParsedArgs.parseOptions(arrayOf("--style=google", "@unknown", "--help", "file.kt")) + val parseResult = parseOptions("--style=google", "@unknown", "--help", "file.kt") assertThat(parseResult).isInstanceOf(ParseResult.ShowMessage::class.java) } @Test fun `parseOptions recognises --version`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("--version")) + val parseResult = parseOptions("--version") assertThat(parseResult).isInstanceOf(ParseResult.ShowMessage::class.java) } @Test fun `parseOptions recognises -v`() { - val parseResult = ParsedArgs.parseOptions(arrayOf("-v")) + val parseResult = parseOptions("-v") assertThat(parseResult).isInstanceOf(ParseResult.ShowMessage::class.java) } @Test fun `arg --version overrides all others`() { - val parseResult = - ParsedArgs.parseOptions(arrayOf("--style=google", "@unknown", "--version", "file.kt")) + val parseResult = parseOptions("--style=google", "@unknown", "--version", "file.kt") assertThat(parseResult).isInstanceOf(ParseResult.ShowMessage::class.java) } @@ -313,8 +313,11 @@ class ParsedArgsTest { @Test fun `parses multiple args successfully`() { val testResult = - ParsedArgs.parseOptions( - arrayOf("--google-style", "--dry-run", "--set-exit-if-changed", "File.kt"), + parseOptions( + "--google-style", + "--dry-run", + "--set-exit-if-changed", + "File.kt", ) assertThat(testResult) .isEqualTo( @@ -329,8 +332,7 @@ class ParsedArgsTest { @Test fun `last style in args wins`() { - val testResult = - ParsedArgs.parseOptions(arrayOf("--google-style", "--kotlinlang-style", "File.kt")) + val testResult = parseOptions("--google-style", "--kotlinlang-style", "File.kt") assertThat(testResult) .isEqualTo( parseResultOk( @@ -342,10 +344,12 @@ class ParsedArgsTest { @Test fun `error when parsing multiple args and one is unknown`() { - val testResult = ParsedArgs.parseOptions(arrayOf("@unknown", "--google-style", "File.kt")) + val testResult = parseOptions("@unknown", "--google-style", "File.kt") assertThat(testResult).isEqualTo(ParseResult.Error("Unexpected option: @unknown")) } + private fun parseOptions(vararg options: String): ParseResult = ParsedArgs.parseOptions(options) + private fun assertSucceeds(parseResult: ParseResult): ParsedArgs { assertThat(parseResult).isInstanceOf(ParseResult.Ok::class.java) return (parseResult as ParseResult.Ok).parsedValue From e02a41ab9eaba4221ee66890fb7501e1eb36c538 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 22 Jun 2026 00:36:49 -0400 Subject: [PATCH 16/17] Consolidate lineRanges into ranges() --- .../test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt index 227b9a2e..dc32b197 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt @@ -129,7 +129,7 @@ class ParsedArgsTest { assertThat(parsed.lineRanges) .isEqualTo( - lineRanges( + ranges( Range.closedOpen(0, 3), Range.closedOpen(4, 5), Range.closedOpen(6, 7), @@ -143,11 +143,11 @@ class ParsedArgsTest { .isEqualTo( parseResultOk( fileNames = listOf("foo.kt"), - lineRanges = lineRanges(Range.closedOpen(0, 1)), + lineRanges = ranges(Range.closedOpen(0, 1)), ) ) assertThat(assertSucceeds(parseOptions("--line", "2", "foo.kt")).lineRanges) - .isEqualTo(lineRanges(Range.closedOpen(1, 2))) + .isEqualTo(ranges(Range.closedOpen(1, 2))) } @Test @@ -384,10 +384,6 @@ class ParsedArgsTest { return ParseResult.Ok(parsedArgs) } - private fun lineRanges(vararg ranges: Range): RangeSet { - return ranges(*ranges) - } - private fun ranges(vararg ranges: Range): RangeSet { val lineRanges = TreeRangeSet.create() for (range in ranges) { From 72e3e0148192834b65e67c08e0c87047dab41183 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Mon, 22 Jun 2026 00:39:54 -0400 Subject: [PATCH 17/17] Simplify ParsedArgsTest range assertions --- .../com/facebook/ktfmt/cli/ParsedArgsTest.kt | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt index dc32b197..447f598f 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt @@ -139,13 +139,10 @@ class ParsedArgsTest { @Test fun `parseOptions recognizes --line alias`() { - assertThat(parseOptions("--line=1", "foo.kt")) - .isEqualTo( - parseResultOk( - fileNames = listOf("foo.kt"), - lineRanges = ranges(Range.closedOpen(0, 1)), - ) - ) + val parsed = assertSucceeds(parseOptions("--line=1", "foo.kt")) + assertThat(parsed.fileNames).containsExactly("foo.kt") + assertThat(parsed.lineRanges).isEqualTo(ranges(Range.closedOpen(0, 1))) + assertThat(assertSucceeds(parseOptions("--line", "2", "foo.kt")).lineRanges) .isEqualTo(ranges(Range.closedOpen(1, 2))) } @@ -364,12 +361,10 @@ class ParsedArgsTest { stdinName: String? = null, editorConfig: Boolean = false, quiet: Boolean = false, - lineRanges: RangeSet = TreeRangeSet.create(), - characterRanges: RangeSet = TreeRangeSet.create(), ): ParseResult.Ok { val returnedFormattingOptions = formattingOptions.copy(removeUnusedImports = removedUnusedImports) - val parsedArgs = + return ParseResult.Ok( ParsedArgs( fileNames, returnedFormattingOptions, @@ -379,9 +374,7 @@ class ParsedArgsTest { editorConfig, quiet, ) - parsedArgs.lineRanges.addAll(lineRanges) - parsedArgs.characterRanges.addAll(characterRanges) - return ParseResult.Ok(parsedArgs) + ) } private fun ranges(vararg ranges: Range): RangeSet {