Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ $ brew install ktfmt
$ java -jar /path/to/ktfmt-<VERSION>-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.

Expand Down
3 changes: 3 additions & 0 deletions core/api/ktfmt.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion core/src/main/java/com/facebook/ktfmt/cli/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -190,4 +200,7 @@ class Main(
throw e
}
}

private fun ParsedArgs.isPartialFormat(): Boolean =
!lineRanges.isEmpty || !characterRanges.isEmpty
}
144 changes: 141 additions & 3 deletions core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<Int> = TreeRangeSet.create()
/** Zero-indexed character ranges to format, using closed-open bounds. */
internal val characterRanges: RangeSet<Int> = TreeRangeSet.create()

companion object {

fun processArgs(args: Array<String>): ParseResult {
Expand Down Expand Up @@ -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> Name to report when formatting code from stdin
| --lines=<lines> Line range(s) to format, like 5 or 1:12,14.
| May be used multiple times.
| --offset=<offset> Character offset to format, paired with --length.
| May be used multiple times.
| --length=<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
Expand Down Expand Up @@ -114,13 +129,26 @@ data class ParsedArgs(
var stdinName: String? = null
var editorConfig = false
var quiet = false
val lineRanges = TreeRangeSet.create<Int>()
val offsets = mutableListOf<Int>()
val lengths = mutableListOf<Int>()

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
Expand All @@ -136,10 +164,65 @@ data class ParsedArgs(
?: return ParseResult.Error(
"Found option '${arg}', expected '${"--stdin-name"}=<value>'"
)
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("-")) {
Expand All @@ -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<Int>()
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),
Expand All @@ -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<Int>,
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<Int> {
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)
}
}
}
}

Expand Down
Loading
Loading