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 `@param` tag, returns the corresponding parameter name,
143 * otherwise null.
144 */
getParamNamenull145 fun String.getParamName(): String? {
146 val length = this.length
147 var start = 0
148 while (start < length && this[start].isWhitespace()) {
149 start++
150 }
151 if (!this.startsWith("@param", start)) {
152 return null
153 }
154 start += "@param".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
getIndentnull190 private fun getIndent(start: Int, lookup: (Int) -> Char): String {
191 var i = start - 1
192 while (i >= 0 && lookup(i) != '\n') {
193 i--
194 }
195 val sb = StringBuilder()
196 for (j in i + 1 until start) {
197 sb.append(lookup(j))
198 }
199 return sb.toString()
200 }
201
202 /**
203 * Given a character [lookup] function in a document of [max] characters, for a comment starting at
204 * offset [start], compute the effective indent on the first line and on subsequent lines.
205 *
206 * For a comment starting on its own line, the two will be the same. But for a comment that is at
207 * the end of a line containing code, the first line indent will not be the indentation of the
208 * earlier code, it will be the full indent as if all the code characters were whitespace characters
209 * (which lets the formatter figure out how much space is available on the first line).
210 */
computeIndentsnull211 fun computeIndents(start: Int, lookup: (Int) -> Char, max: Int): Pair<String, String> {
212 val originalIndent = getIndent(start, lookup)
213 val suffix = !originalIndent.all { it.isWhitespace() }
214 val indent =
215 if (suffix) {
216 originalIndent.map { if (it.isWhitespace()) it else ' ' }.joinToString(separator = "")
217 } else {
218 originalIndent
219 }
220
221 val secondaryIndent =
222 if (suffix) {
223 // We don't have great heuristics to figure out what the indent should be
224 // following a source line -- e.g. it can be implied by things like whether
225 // the line ends with '{' or an operator, but it's more complicated than
226 // that. So we'll cheat and just look to see what the existing code does!
227 var offset = start
228 while (offset < max && lookup(offset) != '\n') {
229 offset++
230 }
231 offset++
232 val sb = StringBuilder()
233 while (offset < max) {
234 if (lookup(offset) == '\n') {
235 sb.clear()
236 } else {
237 val c = lookup(offset)
238 if (c.isWhitespace()) {
239 sb.append(c)
240 } else {
241 if (c == '*') {
242 // in a comment, the * is often one space indented
243 // to line up with the first * in the opening /** and
244 // the actual indent should be aligned with the /
245 sb.setLength(sb.length - 1)
246 }
247 break
248 }
249 }
250 offset++
251 }
252 sb.toString()
253 } else {
254 originalIndent
255 }
256
257 return Pair(indent, secondaryIndent)
258 }
259
260 /**
261 * Attempt to preserve the caret position across reformatting. Returns the delta in the new comment.
262 */
findSamePositionnull263 fun findSamePosition(comment: String, delta: Int, reformattedComment: String): Int {
264 // First see if the two comments are identical up to the delta; if so, same
265 // new position
266 for (i in 0 until min(comment.length, reformattedComment.length)) {
267 if (i == delta) {
268 return delta
269 } else if (comment[i] != reformattedComment[i]) {
270 break
271 }
272 }
273
274 var i = comment.length - 1
275 var j = reformattedComment.length - 1
276 if (delta == i + 1) {
277 return j + 1
278 }
279 while (i >= 0 && j >= 0) {
280 if (i == delta) {
281 return j
282 }
283 if (comment[i] != reformattedComment[j]) {
284 break
285 }
286 i--
287 j--
288 }
289
290 fun isSignificantChar(c: Char): Boolean = c.isWhitespace() || c == '*'
291
292 // Finally it's somewhere in the middle; search by character skipping over
293 // insignificant characters (space, *, etc)
294 fun nextSignificantChar(s: String, from: Int): Int {
295 var curr = from
296 while (curr < s.length) {
297 val c = s[curr]
298 if (isSignificantChar(c)) {
299 curr++
300 } else {
301 break
302 }
303 }
304 return curr
305 }
306
307 var offset = 0
308 var reformattedOffset = 0
309 while (offset < delta && reformattedOffset < reformattedComment.length) {
310 offset = nextSignificantChar(comment, offset)
311 reformattedOffset = nextSignificantChar(reformattedComment, reformattedOffset)
312 if (offset == delta) {
313 return reformattedOffset
314 }
315 offset++
316 reformattedOffset++
317 }
318 return reformattedOffset
319 }
320
321 // Until stdlib version is no longer experimental
maxOfnull322 fun <T, R : Comparable<R>> Iterable<T>.maxOf(selector: (T) -> R): R {
323 val iterator = iterator()
324 if (!iterator.hasNext()) throw NoSuchElementException()
325 var maxValue = selector(iterator.next())
326 while (iterator.hasNext()) {
327 val v = selector(iterator.next())
328 if (maxValue < v) {
329 maxValue = v
330 }
331 }
332 return maxValue
333 }
334