diff --git a/CHANGELOG.md b/CHANGELOG.md index 075ad6ef..6edad6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ 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. +- Support selected-range formatting in the IntelliJ plugin. + ### 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/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/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 } 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..ccab9d7e 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 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 @@ -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,65 @@ data class ParsedArgs( ?: return ParseResult.Error( "Found option '${arg}', expected '${"--stdin-name"}='" ) + 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 = + 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("--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("invalid integer value for $key: $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("invalid integer value for $key: $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 +238,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 +262,54 @@ 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 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/main/java/com/facebook/ktfmt/format/Formatter.kt b/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt index 24864b5a..6382d555 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,27 @@ 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 +117,61 @@ 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 { dropRedundantElements(it, options) } + .transform { sortedAndDistinctImports(it, trimLeadingWhitespace = true) } + .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 +189,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) @@ -158,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 @@ -201,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, ) 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..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,7 +436,9 @@ class KotlinInputAstVisitor( builder.blankLineWanted(OpsBuilder.BlankLineWanted.PRESERVE) } first = false + markForPartialFormat() visitStatement(statement) + markForPartialFormat() } } @@ -2144,7 +2146,9 @@ class KotlinInputAstVisitor( } builder.blankLineWanted(blankLineBetweenMembers) + markForPartialFormat() builder.block(ZERO) { visit(curr) } + markForPartialFormat() builder.guessToken(";") builder.forcedBreak() @@ -2696,7 +2700,9 @@ class KotlinInputAstVisitor( } ) + builder.markForPartialFormat() visit(child) + builder.markForPartialFormat() isFirst = false } markForPartialFormat() @@ -2722,8 +2728,10 @@ class KotlinInputAstVisitor( ) { builder.blankLineWanted(OpsBuilder.BlankLineWanted.YES) } + builder.markForPartialFormat() visit(child) builder.guessToken(";") + builder.markForPartialFormat() lastChildHadBlankLineBefore = childGetsBlankLineBefore lastChildIsContextReceiver = child is KtScriptInitializer && 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..a41b3fe6 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,293 @@ class MainTest { assertThat(returnValue).isEqualTo(1) assertThat(err.toString(testCharset)).contains("foo.kt:1:14: error: ") } + + @Test + fun `--lines formats the selected file statement`() { + 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 formats the selected stdin statement`() { + 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 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 = + """ + |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") + } } 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..447f598f 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 @@ -40,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 @@ -62,124 +65,220 @@ 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(parseOptions("--lines=1:3,5", "--lines", "7", "foo.kt")) + + assertThat(parsed.lineRanges) + .isEqualTo( + ranges( + Range.closedOpen(0, 3), + Range.closedOpen(4, 5), + Range.closedOpen(6, 7), + ) + ) + } + + @Test + fun `parseOptions recognizes --line alias`() { + 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))) + } + + @Test + fun `parseOptions recognizes offset and length pairs`() { + val parsed = + assertSucceeds( + parseOptions( + "--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 = parseOptions("--lines") + assertThat(parseResult) + .isEqualTo(ParseResult.Error("required value was not provided for: --lines")) + } + + @Test + fun `parseOptions rejects invalid --lines range`() { + 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 = parseOptions("--offset") + assertThat(parseResult) + .isEqualTo(ParseResult.Error("required value was not provided for: --offset")) + } + + @Test + fun `parseOptions rejects invalid --offset`() { + 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 = parseOptions("--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 = 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 = 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) } @@ -211,8 +310,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( @@ -227,8 +329,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( @@ -240,10 +341,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 @@ -273,4 +376,12 @@ class ParsedArgsTest { ) ) } + + private fun ranges(vararg ranges: Range): RangeSet { + val lineRanges = TreeRangeSet.create() + for (range in ranges) { + lineRanges.add(range) + } + return lineRanges + } } 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 + } } }