xref: /aosp_15_r20/external/ktfmt/core/src/main/java/com/facebook/ktfmt/kdoc/Utilities.kt (revision 5be3f65c8cf0e6db0a7e312df5006e8e93cdf9ec)
1 /*
2  * Copyright (c) Tor Norbye.
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 package com.facebook.ktfmt.kdoc
18 
19 import java.util.regex.Pattern
20 import kotlin.math.min
21 
getIndentnull22 fun getIndent(width: Int): String {
23   val sb = StringBuilder()
24   for (i in 0 until width) {
25     sb.append(' ')
26   }
27   return sb.toString()
28 }
29 
getIndentSizenull30 fun getIndentSize(indent: String, options: KDocFormattingOptions): Int {
31   var size = 0
32   for (c in indent) {
33     if (c == '\t') {
34       size += options.tabWidth
35     } else {
36       size++
37     }
38   }
39   return size
40 }
41 
42 /** Returns line number (1-based) */
getLineNumbernull43 fun getLineNumber(source: String, offset: Int, startLine: Int = 1, startOffset: Int = 0): Int {
44   var line = startLine
45   for (i in startOffset until offset) {
46     val c = source[i]
47     if (c == '\n') {
48       line++
49     }
50   }
51   return line
52 }
53 
54 private val numberPattern = Pattern.compile("^\\d+([.)]) ")
55 
Stringnull56 fun String.isListItem(): Boolean {
57   return startsWith("- ") ||
58       startsWith("* ") ||
59       startsWith("+ ") ||
60       firstOrNull()?.isDigit() == true && numberPattern.matcher(this).find() ||
61       startsWith("<li>", ignoreCase = true)
62 }
63 
collapseSpacesnull64 fun String.collapseSpaces(): String {
65   if (indexOf("  ") == -1) {
66     return this.trimEnd()
67   }
68   val sb = StringBuilder()
69   var prev: Char = this[0]
70   for (i in indices) {
71     if (prev == ' ') {
72       if (this[i] == ' ') {
73         continue
74       }
75     }
76     sb.append(this[i])
77     prev = this[i]
78   }
79   return sb.trimEnd().toString()
80 }
81 
isTodonull82 fun String.isTodo(): Boolean {
83   return startsWith("TODO:") || startsWith("TODO(")
84 }
85 
isHeadernull86 fun String.isHeader(): Boolean {
87   return startsWith("#") || startsWith("<h", true)
88 }
89 
isQuotednull90 fun String.isQuoted(): Boolean {
91   return startsWith("> ")
92 }
93 
isDirectiveMarkernull94 fun String.isDirectiveMarker(): Boolean {
95   return startsWith("<!--") || startsWith("-->")
96 }
97 
98 /**
99  * Returns true if the string ends with a symbol that implies more text is coming, e.g. ":" or ","
100  */
isExpectingMorenull101 fun String.isExpectingMore(): Boolean {
102   val last = lastOrNull { !it.isWhitespace() } ?: return false
103   return last == ':' || last == ','
104 }
105 
106 /**
107  * Does this String represent a divider line? (Markdown also requires it to be surrounded by empty
108  * lines which has to be checked by the caller)
109  */
Stringnull110 fun String.isLine(minCount: Int = 3): Boolean {
111   return startsWith('-') && containsOnly('-', ' ') && count { it == '-' } >= minCount ||
112       startsWith('_') && containsOnly('_', ' ') && count { it == '_' } >= minCount
113 }
114 
isKDocTagnull115 fun String.isKDocTag(): Boolean {
116   // Not using a hardcoded list here since tags can change over time
117   if (startsWith("@") && length > 1) {
118     for (i in 1 until length) {
119       val c = this[i]
120       if (c.isWhitespace()) {
121         return i > 2
122       } else if (!c.isLetter() || !c.isLowerCase()) {
123         if (c == '[' && (startsWith("@param") || startsWith("@property"))) {
124           // @param is allowed to use brackets -- see
125           // https://kotlinlang.org/docs/kotlin-doc.html#param-name
126           // Example: @param[foo] The description of foo
127           return true
128         } else if (i == 1 && c.isLetter() && c.isUpperCase()) {
129           // Allow capitalized tgs, such as @See -- this is normally a typo; convertMarkup
130           // should also fix these.
131           return true
132         }
133         return false
134       }
135     }
136     return true
137   }
138   return false
139 }
140 
141 /**
142  * If this String represents a KDoc tag named [tag], returns the corresponding parameter name,
143  * otherwise null.
144  */
getTagNamenull145 fun String.getTagName(tag: String): String? {
146   val length = this.length
147   var start = 0
148   while (start < length && this[start].isWhitespace()) {
149     start++
150   }
151   if (!this.startsWith(tag, start)) {
152     return null
153   }
154   start += tag.length
155 
156   while (start < length) {
157     if (this[start].isWhitespace()) {
158       start++
159     } else {
160       break
161     }
162   }
163 
164   if (start < length && this[start] == '[') {
165     start++
166     while (start < length) {
167       if (this[start].isWhitespace()) {
168         start++
169       } else {
170         break
171       }
172     }
173   }
174 
175   var end = start
176   while (end < length) {
177     if (!this[end].isJavaIdentifierPart()) {
178       break
179     }
180     end++
181   }
182 
183   if (end > start) {
184     return this.substring(start, end)
185   }
186 
187   return null
188 }
189 
190 /**
191  * If this String represents a KDoc `@param` or `@property` tag, returns the corresponding parameter
192  * name, otherwise null.
193  */
getParamNamenull194 fun String.getParamName(): String? = getTagName("@param") ?: getTagName("@property")
195 
196 private fun getIndent(start: Int, lookup: (Int) -> Char): String {
197   var i = start - 1
198   while (i >= 0 && lookup(i) != '\n') {
199     i--
200   }
201   val sb = StringBuilder()
202   for (j in i + 1 until start) {
203     sb.append(lookup(j))
204   }
205   return sb.toString()
206 }
207 
208 /**
209  * Given a character [lookup] function in a document of [max] characters, for a comment starting at
210  * offset [start], compute the effective indent on the first line and on subsequent lines.
211  *
212  * For a comment starting on its own line, the two will be the same. But for a comment that is at
213  * the end of a line containing code, the first line indent will not be the indentation of the
214  * earlier code, it will be the full indent as if all the code characters were whitespace characters
215  * (which lets the formatter figure out how much space is available on the first line).
216  */
computeIndentsnull217 fun computeIndents(start: Int, lookup: (Int) -> Char, max: Int): Pair<String, String> {
218   val originalIndent = getIndent(start, lookup)
219   val suffix = !originalIndent.all { it.isWhitespace() }
220   val indent =
221       if (suffix) {
222         originalIndent.map { if (it.isWhitespace()) it else ' ' }.joinToString(separator = "")
223       } else {
224         originalIndent
225       }
226 
227   val secondaryIndent =
228       if (suffix) {
229         // We don't have great heuristics to figure out what the indent should be
230         // following a source line -- e.g. it can be implied by things like whether
231         // the line ends with '{' or an operator, but it's more complicated than
232         // that. So we'll cheat and just look to see what the existing code does!
233         var offset = start
234         while (offset < max && lookup(offset) != '\n') {
235           offset++
236         }
237         offset++
238         val sb = StringBuilder()
239         while (offset < max) {
240           if (lookup(offset) == '\n') {
241             sb.clear()
242           } else {
243             val c = lookup(offset)
244             if (c.isWhitespace()) {
245               sb.append(c)
246             } else {
247               if (c == '*') {
248                 // in a comment, the * is often one space indented
249                 // to line up with the first * in the opening /** and
250                 // the actual indent should be aligned with the /
251                 sb.setLength(sb.length - 1)
252               }
253               break
254             }
255           }
256           offset++
257         }
258         sb.toString()
259       } else {
260         originalIndent
261       }
262 
263   return Pair(indent, secondaryIndent)
264 }
265 
266 /**
267  * Attempt to preserve the caret position across reformatting. Returns the delta in the new comment.
268  */
findSamePositionnull269 fun findSamePosition(comment: String, delta: Int, reformattedComment: String): Int {
270   // First see if the two comments are identical up to the delta; if so, same
271   // new position
272   for (i in 0 until min(comment.length, reformattedComment.length)) {
273     if (i == delta) {
274       return delta
275     } else if (comment[i] != reformattedComment[i]) {
276       break
277     }
278   }
279 
280   var i = comment.length - 1
281   var j = reformattedComment.length - 1
282   if (delta == i + 1) {
283     return j + 1
284   }
285   while (i >= 0 && j >= 0) {
286     if (i == delta) {
287       return j
288     }
289     if (comment[i] != reformattedComment[j]) {
290       break
291     }
292     i--
293     j--
294   }
295 
296   fun isSignificantChar(c: Char): Boolean = c.isWhitespace() || c == '*'
297 
298   // Finally it's somewhere in the middle; search by character skipping over
299   // insignificant characters (space, *, etc)
300   fun nextSignificantChar(s: String, from: Int): Int {
301     var curr = from
302     while (curr < s.length) {
303       val c = s[curr]
304       if (isSignificantChar(c)) {
305         curr++
306       } else {
307         break
308       }
309     }
310     return curr
311   }
312 
313   var offset = 0
314   var reformattedOffset = 0
315   while (offset < delta && reformattedOffset < reformattedComment.length) {
316     offset = nextSignificantChar(comment, offset)
317     reformattedOffset = nextSignificantChar(reformattedComment, reformattedOffset)
318     if (offset == delta) {
319       return reformattedOffset
320     }
321     offset++
322     reformattedOffset++
323   }
324   return reformattedOffset
325 }
326 
327 // Until stdlib version is no longer experimental
maxOfnull328 fun <T, R : Comparable<R>> Iterable<T>.maxOf(selector: (T) -> R): R {
329   val iterator = iterator()
330   if (!iterator.hasNext()) throw NoSuchElementException()
331   var maxValue = selector(iterator.next())
332   while (iterator.hasNext()) {
333     val v = selector(iterator.next())
334     if (maxValue < v) {
335       maxValue = v
336     }
337   }
338   return maxValue
339 }
340