• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Portions Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 /*
18  * Copyright (c) Tor Norbye.
19  *
20  * Licensed under the Apache License, Version 2.0 (the "License");
21  * you may not use this file except in compliance with the License.
22  * You may obtain a copy of the License at
23  *
24  *     http://www.apache.org/licenses/LICENSE-2.0
25  *
26  * Unless required by applicable law or agreed to in writing, software
27  * distributed under the License is distributed on an "AS IS" BASIS,
28  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
29  * See the License for the specific language governing permissions and
30  * limitations under the License.
31  */
32 
33 package com.facebook.ktfmt.kdoc
34 
35 import java.util.regex.Pattern
36 import kotlin.math.min
37 
getIndentnull38 fun getIndent(width: Int): String {
39   val sb = StringBuilder()
40   for (i in 0 until width) {
41     sb.append(' ')
42   }
43   return sb.toString()
44 }
45 
getIndentSizenull46 fun getIndentSize(indent: String, options: KDocFormattingOptions): Int {
47   var size = 0
48   for (c in indent) {
49     if (c == '\t') {
50       size += options.tabWidth
51     } else {
52       size++
53     }
54   }
55   return size
56 }
57 
58 /** Returns line number (1-based) */
getLineNumbernull59 fun getLineNumber(source: String, offset: Int, startLine: Int = 1, startOffset: Int = 0): Int {
60   var line = startLine
61   for (i in startOffset until offset) {
62     val c = source[i]
63     if (c == '\n') {
64       line++
65     }
66   }
67   return line
68 }
69 
70 private val numberPattern = Pattern.compile("^\\d+([.)]) ")
71 
Stringnull72 fun String.isListItem(): Boolean {
73   return startsWith("- ") ||
74       startsWith("* ") ||
75       startsWith("+ ") ||
76       (firstOrNull()?.isDigit() == true && numberPattern.matcher(this).find()) ||
77       startsWith("<li>", ignoreCase = true)
78 }
79 
collapseSpacesnull80 fun String.collapseSpaces(): String {
81   if (indexOf("  ") == -1) {
82     return this.trimEnd()
83   }
84   val sb = StringBuilder()
85   var prev: Char = this[0]
86   for (i in indices) {
87     if (prev == ' ') {
88       if (this[i] == ' ') {
89         continue
90       }
91     }
92     sb.append(this[i])
93     prev = this[i]
94   }
95   return sb.trimEnd().toString()
96 }
97 
isTodonull98 fun String.isTodo(): Boolean {
99   return startsWith("TODO:") || startsWith("TODO(")
100 }
101 
isHeadernull102 fun String.isHeader(): Boolean {
103   return startsWith("#") || startsWith("<h", true)
104 }
105 
isQuotednull106 fun String.isQuoted(): Boolean {
107   return startsWith("> ")
108 }
109 
isDirectiveMarkernull110 fun String.isDirectiveMarker(): Boolean {
111   return startsWith("<!--") || startsWith("-->")
112 }
113 
114 /**
115  * Returns true if the string ends with a symbol that implies more text is coming, e.g. ":" or ","
116  */
isExpectingMorenull117 fun String.isExpectingMore(): Boolean {
118   val last = lastOrNull { !it.isWhitespace() } ?: return false
119   return last == ':' || last == ','
120 }
121 
122 /**
123  * Does this String represent a divider line? (Markdown also requires it to be surrounded by empty
124  * lines which has to be checked by the caller)
125  */
Stringnull126 fun String.isLine(minCount: Int = 3): Boolean {
127   return (startsWith('-') && containsOnly('-', ' ') && count { it == '-' } >= minCount) ||
128       (startsWith('_') && containsOnly('_', ' ') && count { it == '_' } >= minCount)
129 }
130 
isKDocTagnull131 fun String.isKDocTag(): Boolean {
132   // Not using a hardcoded list here since tags can change over time
133   if (startsWith("@") && length > 1) {
134     for (i in 1 until length) {
135       val c = this[i]
136       if (c.isWhitespace()) {
137         return i > 2
138       } else if (!c.isLetter() || !c.isLowerCase()) {
139         if (c == '[' && (startsWith("@param") || startsWith("@property"))) {
140           // @param is allowed to use brackets -- see
141           // https://kotlinlang.org/docs/kotlin-doc.html#param-name
142           // Example: @param[foo] The description of foo
143           return true
144         } else if (i == 1 && c.isLetter() && c.isUpperCase()) {
145           // Allow capitalized tgs, such as @See -- this is normally a typo; convertMarkup
146           // should also fix these.
147           return true
148         }
149         return false
150       }
151     }
152     return true
153   }
154   return false
155 }
156 
157 /**
158  * If this String represents a KDoc tag named [tag], returns the corresponding parameter name,
159  * otherwise null.
160  */
getTagNamenull161 fun String.getTagName(tag: String): String? {
162   val length = this.length
163   var start = 0
164   while (start < length && this[start].isWhitespace()) {
165     start++
166   }
167   if (!this.startsWith(tag, start)) {
168     return null
169   }
170   start += tag.length
171 
172   while (start < length) {
173     if (this[start].isWhitespace()) {
174       start++
175     } else {
176       break
177     }
178   }
179 
180   if (start < length && this[start] == '[') {
181     start++
182     while (start < length) {
183       if (this[start].isWhitespace()) {
184         start++
185       } else {
186         break
187       }
188     }
189   }
190 
191   var end = start
192   while (end < length) {
193     if (!this[end].isJavaIdentifierPart()) {
194       break
195     }
196     end++
197   }
198 
199   if (end > start) {
200     return this.substring(start, end)
201   }
202 
203   return null
204 }
205 
206 /**
207  * If this String represents a KDoc `@param` or `@property` tag, returns the corresponding parameter
208  * name, otherwise null.
209  */
getParamNamenull210 fun String.getParamName(): String? = getTagName("@param") ?: getTagName("@property")
211 
212 private fun getIndent(start: Int, lookup: (Int) -> Char): String {
213   var i = start - 1
214   while (i >= 0 && lookup(i) != '\n') {
215     i--
216   }
217   val sb = StringBuilder()
218   for (j in i + 1 until start) {
219     sb.append(lookup(j))
220   }
221   return sb.toString()
222 }
223 
224 /**
225  * Given a character [lookup] function in a document of [max] characters, for a comment starting at
226  * offset [start], compute the effective indent on the first line and on subsequent lines.
227  *
228  * For a comment starting on its own line, the two will be the same. But for a comment that is at
229  * the end of a line containing code, the first line indent will not be the indentation of the
230  * earlier code, it will be the full indent as if all the code characters were whitespace characters
231  * (which lets the formatter figure out how much space is available on the first line).
232  */
computeIndentsnull233 fun computeIndents(start: Int, lookup: (Int) -> Char, max: Int): Pair<String, String> {
234   val originalIndent = getIndent(start, lookup)
235   val suffix = !originalIndent.all { it.isWhitespace() }
236   val indent =
237       if (suffix) {
238         originalIndent.map { if (it.isWhitespace()) it else ' ' }.joinToString(separator = "")
239       } else {
240         originalIndent
241       }
242 
243   val secondaryIndent =
244       if (suffix) {
245         // We don't have great heuristics to figure out what the indent should be
246         // following a source line -- e.g. it can be implied by things like whether
247         // the line ends with '{' or an operator, but it's more complicated than
248         // that. So we'll cheat and just look to see what the existing code does!
249         var offset = start
250         while (offset < max && lookup(offset) != '\n') {
251           offset++
252         }
253         offset++
254         val sb = StringBuilder()
255         while (offset < max) {
256           if (lookup(offset) == '\n') {
257             sb.clear()
258           } else {
259             val c = lookup(offset)
260             if (c.isWhitespace()) {
261               sb.append(c)
262             } else {
263               if (c == '*') {
264                 // in a comment, the * is often one space indented
265                 // to line up with the first * in the opening /** and
266                 // the actual indent should be aligned with the /
267                 sb.setLength(sb.length - 1)
268               }
269               break
270             }
271           }
272           offset++
273         }
274         sb.toString()
275       } else {
276         originalIndent
277       }
278 
279   return Pair(indent, secondaryIndent)
280 }
281 
282 /**
283  * Attempt to preserve the caret position across reformatting. Returns the delta in the new comment.
284  */
findSamePositionnull285 fun findSamePosition(comment: String, delta: Int, reformattedComment: String): Int {
286   // First see if the two comments are identical up to the delta; if so, same
287   // new position
288   for (i in 0 until min(comment.length, reformattedComment.length)) {
289     if (i == delta) {
290       return delta
291     } else if (comment[i] != reformattedComment[i]) {
292       break
293     }
294   }
295 
296   var i = comment.length - 1
297   var j = reformattedComment.length - 1
298   if (delta == i + 1) {
299     return j + 1
300   }
301   while (i >= 0 && j >= 0) {
302     if (i == delta) {
303       return j
304     }
305     if (comment[i] != reformattedComment[j]) {
306       break
307     }
308     i--
309     j--
310   }
311 
312   fun isSignificantChar(c: Char): Boolean = c.isWhitespace() || c == '*'
313 
314   // Finally it's somewhere in the middle; search by character skipping over
315   // insignificant characters (space, *, etc)
316   fun nextSignificantChar(s: String, from: Int): Int {
317     var curr = from
318     while (curr < s.length) {
319       val c = s[curr]
320       if (isSignificantChar(c)) {
321         curr++
322       } else {
323         break
324       }
325     }
326     return curr
327   }
328 
329   var offset = 0
330   var reformattedOffset = 0
331   while (offset < delta && reformattedOffset < reformattedComment.length) {
332     offset = nextSignificantChar(comment, offset)
333     reformattedOffset = nextSignificantChar(reformattedComment, reformattedOffset)
334     if (offset == delta) {
335       return reformattedOffset
336     }
337     offset++
338     reformattedOffset++
339   }
340   return reformattedOffset
341 }
342 
343 // Until stdlib version is no longer experimental
maxOfnull344 fun <T, R : Comparable<R>> Iterable<T>.maxOf(selector: (T) -> R): R {
345   val iterator = iterator()
346   if (!iterator.hasNext()) throw NoSuchElementException()
347   var maxValue = selector(iterator.next())
348   while (iterator.hasNext()) {
349     val v = selector(iterator.next())
350     if (maxValue < v) {
351       maxValue = v
352     }
353   }
354   return maxValue
355 }
356