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