Try to keep package names together

Summary: When seeing things that look as package names, according to the first reference name in them, we will now try to keep them together.

Reviewed By: cgrushko

Differential Revision: D20251444

fbshipit-source-id: 54dc01b785500235b209bd8cbde966de26227bb0
diff --git a/core/src/main/java/com/facebook/ktfmt/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/KotlinInputAstVisitor.kt
index 370beac..54a88c7 100644
--- a/core/src/main/java/com/facebook/ktfmt/KotlinInputAstVisitor.kt
+++ b/core/src/main/java/com/facebook/ktfmt/KotlinInputAstVisitor.kt
@@ -421,15 +421,25 @@
 
     builder.block(ZERO) {
       val leftMostExpression = parts.first()
-      leftMostExpression.receiverExpression.accept(this)
+      val leftMostReceiverExpression = leftMostExpression.receiverExpression
+      val typePrefixSections = getTypePrefixLength(expression)
+      var count = 0
+      if (typePrefixSections > 0) {
+        builder.open(ZERO)
+      }
+      leftMostReceiverExpression.accept(this)
       for (receiver in parts) {
         val isFirst = receiver === leftMostExpression
         if (!isFirst || receiver.receiverExpression is KtCallExpression) {
           builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakIndent)
         }
         builder.token(receiver.operationSign.value)
+        val selectorExpression = receiver.selectorExpression
         builder.block(if (isFirst) ZERO else expressionBreakIndent) {
-          receiver.selectorExpression?.accept(this)
+          selectorExpression?.accept(this)
+        }
+        if (typePrefixSections > 0 && ++count == typePrefixSections) {
+          builder.close()
         }
       }
     }
@@ -1631,7 +1641,7 @@
   }
 
   /**
-   * Emit a [Doc.Token] .
+   * Emit a [Doc.Token].
    *
    * @param token the [String] to wrap in a [Doc.Token]
    * @param plusIndentCommentsBefore extra block for comments before this token
diff --git a/core/src/main/java/com/facebook/ktfmt/TypePrefix.kt b/core/src/main/java/com/facebook/ktfmt/TypePrefix.kt
new file mode 100644
index 0000000..f0e0055
--- /dev/null
+++ b/core/src/main/java/com/facebook/ktfmt/TypePrefix.kt
@@ -0,0 +1,40 @@
+// (c) Facebook, Inc. and its affiliates. Confidential and proprietary.
+
+package com.facebook.ktfmt
+
+import org.jetbrains.kotlin.psi.KtCallExpression
+import org.jetbrains.kotlin.psi.KtExpression
+import org.jetbrains.kotlin.psi.KtQualifiedExpression
+import org.jetbrains.kotlin.psi.KtReferenceExpression
+
+/**
+ * A list of common package name initial domain names This is used in order to avoid splitting
+ * package names when possible
+ */
+private val packageNames = setOf("com", "gov", "java", "javax", "kotlin", "org")
+
+/**
+ * Guesses the amount of parts in a qualified expression that might represent a type name
+ *
+ * Examples:
+ * ```
+ * com.facebook.ktfmt.Formatter.doIt() -> 3
+ * Formatter.doIt() -> 0
+ * myObject.field.anotherField = 0
+ * ```
+ */
+fun getTypePrefixLength(expression: KtQualifiedExpression): Int {
+  var current: KtExpression = expression
+  var count = 0
+  while (current is KtQualifiedExpression) {
+    val selectorExpression = current.selectorExpression
+    if (selectorExpression is KtCallExpression ||
+        (selectorExpression is KtReferenceExpression &&
+            selectorExpression.text?.first()?.isUpperCase() == true)) {
+      count = 0
+    }
+    count++
+    current = current.receiverExpression
+  }
+  return if ((current as? KtReferenceExpression)?.text in packageNames) count else 0
+}
diff --git a/core/src/test/java/com/facebook/ktfmt/FormatterKtTest.kt b/core/src/test/java/com/facebook/ktfmt/FormatterKtTest.kt
index 5b8a6a3..c0f2b58 100644
--- a/core/src/test/java/com/facebook/ktfmt/FormatterKtTest.kt
+++ b/core/src/test/java/com/facebook/ktfmt/FormatterKtTest.kt
@@ -409,6 +409,31 @@
           deduceMaxWidth = true)
 
   @Test
+  fun `avoid breaking suspected package names`() =
+      assertFormatted(
+          """
+      |-----------------------
+      |fun f() {
+      |  com.facebook.Foo
+      |      .format()
+      |  org.facebook.Foo
+      |      .format()
+      |  java.lang.stuff.Foo
+      |      .format()
+      |  javax.lang.stuff.Foo
+      |      .format()
+      |  kotlin.lang.Foo
+      |      .format()
+      |
+      |  // comparison:
+      |  foo.facebook
+      |      .Foo
+      |      .format()
+      |}
+      |""".trimMargin(),
+          deduceMaxWidth = true)
+
+  @Test
   fun `import list`() {
     val code =
         """
diff --git a/core/src/test/java/com/facebook/ktfmt/TypePrefixKtTest.kt b/core/src/test/java/com/facebook/ktfmt/TypePrefixKtTest.kt
new file mode 100644
index 0000000..7e5bff3
--- /dev/null
+++ b/core/src/test/java/com/facebook/ktfmt/TypePrefixKtTest.kt
@@ -0,0 +1,51 @@
+// (c) Facebook, Inc. and its affiliates. Confidential and proprietary.
+
+package com.facebook.ktfmt
+
+import com.intellij.psi.PsiElement
+import org.jetbrains.kotlin.psi.KtQualifiedExpression
+import org.jetbrains.kotlin.psi.KtTreeVisitorVoid
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@Suppress("FunctionNaming")
+@RunWith(JUnit4::class)
+class TypePrefixKtTest {
+
+  @Test
+  fun `when first few names look like package names return proper count`() {
+    val expression = getQualifiedExpression("com.facebook.ktfmt.Formatter.doIt()")
+    assertEquals(3, getTypePrefixLength(expression))
+  }
+
+  @Test
+  fun `when a chain of field calls return 0`() {
+    val expression = getQualifiedExpression("myObject.field.anotherField")
+    assertEquals(0, getTypePrefixLength(expression))
+  }
+
+  @Test
+  fun `when starts with possible class name return 0`() {
+    val expression = getQualifiedExpression("Formatter.doIt()")
+    assertEquals(0, getTypePrefixLength(expression))
+  }
+
+  fun getQualifiedExpression(code: String): KtQualifiedExpression {
+    var result: PsiElement? = null
+    Parser.parse("fun f() { $code }")
+        .accept(
+            object : KtTreeVisitorVoid() {
+              override fun visitElement(element: PsiElement) {
+                if (element.text == code && result == null) {
+                  result = element
+                  return
+                }
+                super.visitElement(element)
+              }
+            })
+
+    return result as KtQualifiedExpression
+  }
+}