Format using replacements instead of dumping the entire formatted output
Summary:
This is the first step for formatting ranges, which is nice to avoid git/hg-blame churn when changing the formatter.
In order for this to work properly, we need to pepper KotlinInputAstVisitor with calls to `markForPartialFormat();`, to tell the formatter what constitutes the smallest formattable range. This is left for a follow-up diff.
Reviewed By: strulovich
Differential Revision: D19843183
fbshipit-source-id: dcd723de07e528223b94f430273d4f35a3fda262
diff --git a/core/src/main/java/com/facebook/ktfmt/Formatter.kt b/core/src/main/java/com/facebook/ktfmt/Formatter.kt
index d1520e4..dc8bd73 100644
--- a/core/src/main/java/com/facebook/ktfmt/Formatter.kt
+++ b/core/src/main/java/com/facebook/ktfmt/Formatter.kt
@@ -15,6 +15,8 @@
package com.facebook.ktfmt
import com.facebook.ktfmt.kdoc.KDocCommentsHelper
+import com.google.common.collect.ImmutableList
+import com.google.common.collect.Range
import com.google.googlejavaformat.Doc
import com.google.googlejavaformat.DocBuilder
import com.google.googlejavaformat.OpsBuilder
@@ -35,22 +37,19 @@
fun format(code: String, maxWidth: Int): String {
val file = Parser.parse(code)
- val javaInput = KotlinInput(code, file)
+ val kotlinInput = KotlinInput(code, file)
val options = JavaFormatterOptions.defaultOptions()
- val javaOutput = JavaOutput("\n", javaInput, KDocCommentsHelper("\n"))
- val builder = OpsBuilder(javaInput, javaOutput)
+ val javaOutput = JavaOutput("\n", kotlinInput, KDocCommentsHelper("\n"))
+ val builder = OpsBuilder(kotlinInput, javaOutput)
file.accept(KotlinInputAstVisitor(builder))
- builder.sync(javaInput.text.length)
+ builder.sync(kotlinInput.text.length)
builder.drain()
val doc = DocBuilder().withOps(builder.build()).build()
doc.computeBreaks(javaOutput.commentsHelper, maxWidth, Doc.State(+0, 0))
doc.write(javaOutput)
javaOutput.flush()
- val stringBuilder = StringBuilder()
- (0 until javaOutput.lineCount).forEach {
- stringBuilder.append(javaOutput.getLine(it))
- stringBuilder.append('\n')
- }
- return stringBuilder.toString()
+ val tokenRangeSet =
+ kotlinInput.characterRangesToTokenRanges(ImmutableList.of(Range.closedOpen(0, code.length)))
+ return JavaOutput.applyReplacements(code, javaOutput.getFormatReplacements(tokenRangeSet))
}
diff --git a/core/src/main/java/com/facebook/ktfmt/KotlinInput.kt b/core/src/main/java/com/facebook/ktfmt/KotlinInput.kt
index ca8ebcf..802f056 100644
--- a/core/src/main/java/com/facebook/ktfmt/KotlinInput.kt
+++ b/core/src/main/java/com/facebook/ktfmt/KotlinInput.kt
@@ -15,12 +15,17 @@
package com.facebook.ktfmt
import com.google.common.base.MoreObjects
+import com.google.common.collect.DiscreteDomain
import com.google.common.collect.ImmutableList
import com.google.common.collect.ImmutableMap
import com.google.common.collect.ImmutableRangeMap
+import com.google.common.collect.Iterables.getLast
import com.google.common.collect.Range
+import com.google.common.collect.RangeSet
+import com.google.common.collect.TreeRangeSet
import com.google.googlejavaformat.Input
import com.google.googlejavaformat.Newlines
+import com.google.googlejavaformat.java.FormatterException
import com.google.googlejavaformat.java.JavaOutput
import com.intellij.openapi.util.text.StringUtil
import com.intellij.psi.PsiComment
@@ -86,6 +91,52 @@
}
}
+ @Throws(FormatterException::class)
+ fun characterRangesToTokenRanges(characterRanges: Collection<Range<Int>>): RangeSet<Int> {
+ val tokenRangeSet = TreeRangeSet.create<Int>()
+ for (characterRange0 in characterRanges) {
+ val characterRange = characterRange0.canonical(DiscreteDomain.integers())
+ tokenRangeSet.add(
+ characterRangeToTokenRange(
+ characterRange.lowerEndpoint(),
+ characterRange.upperEndpoint() - characterRange.lowerEndpoint()))
+ }
+ return tokenRangeSet
+ }
+
+ /**
+ * Convert from an offset and length flag pair to a token range.
+ *
+ * @param offset the `0`-based offset in characters
+ * @param length the length in characters
+ * @return the `0`-based [Range] of tokens
+ * @throws FormatterException
+ */
+ @Throws(FormatterException::class)
+ internal fun characterRangeToTokenRange(offset: Int, length: Int): Range<Int> {
+ var length = length
+ val requiredLength = offset + length
+ if (requiredLength > text.length) {
+ throw FormatterException(
+ String.format(
+ "error: invalid length %d, offset + length (%d) is outside the file",
+ length, requiredLength))
+ }
+ when {
+ length < 0 -> return EMPTY_RANGE
+ length == 0 -> // 0 stands for "format the line under the cursor"
+ length = 1
+ }
+ val enclosed = getPositionTokenMap()
+ .subRangeMap(Range.closedOpen(offset, offset + length))
+ .asMapOfRanges()
+ .values
+ return if (enclosed.isEmpty()) {
+ EMPTY_RANGE
+ } else Range.closedOpen(
+ enclosed.iterator().next().tok.index, getLast(enclosed).getTok().getIndex() + 1)
+ }
+
private fun makePositionToColumnMap(toks: List<KotlinTok>): ImmutableMap<Int, Int> {
val builder = ImmutableMap.builder<Int, Int>()
for (tok in toks) {
diff --git a/core/src/main/java/com/facebook/ktfmt/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/KotlinInputAstVisitor.kt
index 68a6cfe..2a04360 100644
--- a/core/src/main/java/com/facebook/ktfmt/KotlinInputAstVisitor.kt
+++ b/core/src/main/java/com/facebook/ktfmt/KotlinInputAstVisitor.kt
@@ -14,6 +14,8 @@
package com.facebook.ktfmt
+import com.google.common.base.Throwables
+import com.google.common.collect.ImmutableList
import com.google.googlejavaformat.Doc
import com.google.googlejavaformat.FormattingError
import com.google.googlejavaformat.Indent
@@ -123,6 +125,9 @@
/** Standard indentation for a long expression or function call, it is different than block indentation on purpose */
private val expressionBreakIndent: Indent.Const = Indent.Const.make(+4, 1)
+ /** A record of whether we have visited into an expression. */
+ private val inExpression = ArrayDeque(ImmutableList.of(false))
+
/** Example: `fun foo(n: Int) { println(n) }` */
override fun visitNamedFunction(function: KtNamedFunction) {
builder.sync(function)
@@ -1591,6 +1596,47 @@
}
/**
+ * visitElement is called for almost all types of AST nodes.
+ * We use it to keep track of whether we're currently inside an expression or not.
+ */
+ override fun visitElement(element: PsiElement) {
+ inExpression.addLast(element is KtExpression || inExpression.peekLast())
+ val previous = builder.depth()
+ try {
+ super.visitElement(element)
+ } catch (e: FormattingError) {
+ throw e
+ } catch (t: Throwable) {
+ throw FormattingError(builder.diagnostic(Throwables.getStackTraceAsString(t)))
+ } finally {
+ inExpression.removeLast()
+ }
+ builder.checkClosed(previous)
+ }
+
+ override fun visitKtFile(file: KtFile) {
+ markForPartialFormat()
+ super.visitKtFile(file)
+ markForPartialFormat()
+ }
+
+ private fun inExpression(): Boolean {
+ return inExpression.peekLast()
+ }
+
+ /**
+ * markForPartialFormat is used to delineate the smallest areas of code that must be formatted together.
+ *
+ * When only parts of the code are being formatted, the requested area is expanded until it's
+ * covered by an area marked by this method.
+ */
+ private fun markForPartialFormat() {
+ if (!inExpression()) {
+ builder.markForPartialFormat()
+ }
+ }
+
+ /**
* Emit a [Doc.Token].
*
* @param token the [String] to wrap in a [Doc.Token]