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