xref: /aosp_15_r20/external/ktfmt/core/src/main/java/com/facebook/ktfmt/kdoc/ParagraphListBuilder.kt (revision 5be3f65c8cf0e6db0a7e312df5006e8e93cdf9ec)
1 /*
<lambda>null2  * 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 class ParagraphListBuilder(
20     comment: String,
21     private val options: KDocFormattingOptions,
22     private val task: FormattingTask
23 ) {
24   private val lineComment: Boolean = comment.isLineComment()
25   private val commentPrefix: String =
26       if (lineComment) "//" else if (comment.isKDocComment()) "/**" else "/*"
27   private val paragraphs: MutableList<Paragraph> = mutableListOf()
28   private val lines =
29       if (lineComment) {
30         comment.split("\n").map { it.trimStart() }
31       } else if (!comment.contains("\n")) {
32         listOf("* ${comment.removePrefix(commentPrefix).removeSuffix("*/").trim()}")
33       } else {
34         comment.removePrefix(commentPrefix).removeSuffix("*/").trim().split("\n")
35       }
36 
37   private fun lineContent(line: String): String {
38     val trimmed = line.trim()
39     return when {
40       lineComment && trimmed.startsWith("// ") -> trimmed.substring(3)
41       lineComment && trimmed.startsWith("//") -> trimmed.substring(2)
42       trimmed.startsWith("* ") -> trimmed.substring(2)
43       trimmed.startsWith("*") -> trimmed.substring(1)
44       else -> trimmed
45     }
46   }
47 
48   private fun closeParagraph(): Paragraph {
49     val text = paragraph.text
50     when {
51       paragraph.preformatted -> {}
52       text.isKDocTag() -> {
53         paragraph.doc = true
54         paragraph.hanging = true
55       }
56       text.isTodo() -> {
57         paragraph.hanging = true
58       }
59       text.isListItem() -> paragraph.hanging = true
60       text.isDirectiveMarker() -> {
61         paragraph.block = true
62         paragraph.preformatted = true
63       }
64     }
65     if (!paragraph.isEmpty() || paragraph.allowEmpty) {
66       paragraphs.add(paragraph)
67     }
68     return paragraph
69   }
70 
71   private fun newParagraph(): Paragraph {
72     closeParagraph()
73     val prev = paragraph
74     paragraph = Paragraph(task)
75     prev.next = paragraph
76     paragraph.prev = prev
77     return paragraph
78   }
79 
80   private var paragraph = Paragraph(task)
81 
82   private fun appendText(s: String): ParagraphListBuilder {
83     paragraph.content.append(s)
84     return this
85   }
86 
87   private fun addLines(
88       i: Int,
89       includeEnd: Boolean = true,
90       until: (Int, String, String) -> Boolean = { _, _, _ -> true },
91       customize: (Int, Paragraph) -> Unit = { _, _ -> },
92       shouldBreak: (String, String) -> Boolean = { _, _ -> false },
93       separator: String = " "
94   ): Int {
95     var j = i
96     while (j < lines.size) {
97       val l = lines[j]
98       val lineWithIndentation = lineContent(l)
99       val lineWithoutIndentation = lineWithIndentation.trim()
100 
101       if (!includeEnd) {
102         if (j > i && until(j, lineWithoutIndentation, lineWithIndentation)) {
103           stripTrailingBlankLines()
104           return j
105         }
106       }
107 
108       if (shouldBreak(lineWithoutIndentation, lineWithIndentation)) {
109         newParagraph()
110       }
111 
112       if (lineWithIndentation.isQuoted()) {
113         appendText(lineWithoutIndentation.substring(2).collapseSpaces())
114       } else {
115         appendText(lineWithoutIndentation.collapseSpaces())
116       }
117       appendText(separator)
118       customize(j, paragraph)
119       if (includeEnd) {
120         if (j > i && until(j, lineWithoutIndentation, lineWithIndentation)) {
121           stripTrailingBlankLines()
122           return j + 1
123         }
124       }
125 
126       j++
127     }
128 
129     stripTrailingBlankLines()
130     newParagraph()
131 
132     return j
133   }
134 
135   private fun addPreformatted(
136       i: Int,
137       includeStart: Boolean = false,
138       includeEnd: Boolean = true,
139       expectClose: Boolean = false,
140       customize: (Int, Paragraph) -> Unit = { _, _ -> },
141       until: (String) -> Boolean = { true },
142   ): Int {
143     newParagraph()
144     var j = i
145     var foundClose = false
146     var allowCustomize = true
147     while (j < lines.size) {
148       val l = lines[j]
149       val lineWithIndentation = lineContent(l)
150       if (lineWithIndentation.contains("```") &&
151           lineWithIndentation.trimStart().startsWith("```")) {
152         // Don't convert <pre> tags if we already have nested ``` content; that will lead to
153         // trouble
154         allowCustomize = false
155       }
156       val done = (includeStart || j > i) && until(lineWithIndentation)
157       if (!includeEnd && done) {
158         foundClose = true
159         break
160       }
161       j++
162       if (includeEnd && done) {
163         foundClose = true
164         break
165       }
166     }
167 
168     // Don't convert if there's already a mixture
169 
170     // We ran out of lines. This means we had an unterminated preformatted
171     // block. This is unexpected(unless it was an indented block) and most
172     // likely a documentation error (even Dokka will start formatting return
173     // value documentation in preformatted text if you have an opening <pre>
174     // without a closing </pre> before a @return comment), but try to backpedal
175     // a bit such that we don't apply full preformatted treatment everywhere to
176     // things like line breaking.
177     if (!foundClose && expectClose) {
178       // Just add a single line as preformatted and then treat the rest in the
179       // normal way
180       allowCustomize = false
181       j = lines.size
182     }
183 
184     for (index in i until j) {
185       val l = lines[index]
186       val lineWithIndentation = lineContent(l)
187       appendText(lineWithIndentation)
188       paragraph.preformatted = true
189       paragraph.allowEmpty = true
190       if (allowCustomize) {
191         customize(index, paragraph)
192       }
193       newParagraph()
194     }
195     stripTrailingBlankLines()
196     newParagraph()
197 
198     return j
199   }
200 
201   private fun stripTrailingBlankLines() {
202     for (p in paragraphs.size - 1 downTo 0) {
203       val paragraph = paragraphs[p]
204       if (!paragraph.isEmpty()) {
205         break
206       }
207       paragraphs.removeAt(p)
208     }
209   }
210 
211   fun scan(indentSize: Int): ParagraphList {
212     var i = 0
213     while (i < lines.size) {
214       val l = lines[i++]
215       val lineWithIndentation = lineContent(l)
216       val lineWithoutIndentation = lineWithIndentation.trim()
217 
218       fun newParagraph(i: Int): Paragraph {
219         val paragraph = this.newParagraph()
220 
221         if (i >= 0 && i < lines.size) {
222           if (lines[i] == l) {
223             paragraph.originalIndent = lineWithIndentation.length - lineWithoutIndentation.length
224           } else {
225             // We've looked ahead, e.g. when adding lists etc
226             val line = lineContent(lines[i])
227             val trimmed = line.trim()
228             paragraph.originalIndent = line.length - trimmed.length
229           }
230         }
231         return paragraph
232       }
233 
234       if (lineWithIndentation.startsWith("    ") && // markdown preformatted text
235           (i == 1 || lineContent(lines[i - 2]).isBlank()) && // we've already ++'ed i above
236           // Make sure it's not just deeply indented inside a different block
237           (paragraph.prev == null ||
238               lineWithIndentation.length - lineWithoutIndentation.length >=
239                   paragraph.prev!!.originalIndent + 4)) {
240         i = addPreformatted(i - 1, includeEnd = false, expectClose = false) { !it.startsWith(" ") }
241       } else if (lineWithoutIndentation.startsWith("-") &&
242           lineWithoutIndentation.containsOnly('-', '|', ' ')) {
243         val paragraph = newParagraph(i - 1)
244         appendText(lineWithoutIndentation)
245         newParagraph(i).block = true
246         // Dividers must be surrounded by blank lines
247         if (lineWithIndentation.isLine() &&
248             (i < 2 || lineContent(lines[i - 2]).isBlank()) &&
249             (i > lines.size - 1 || lineContent(lines[i]).isBlank())) {
250           paragraph.separator = true
251         }
252       } else if (lineWithoutIndentation.startsWith("=") &&
253           lineWithoutIndentation.containsOnly('=', ' ')) {
254         // Header
255         // ======
256         newParagraph(i - 1).block = true
257         appendText(lineWithoutIndentation)
258         newParagraph(i).block = true
259       } else if (lineWithoutIndentation.startsWith("#")
260       // "## X" is a header, "##X" is not
261       &&
262           lineWithoutIndentation.firstOrNull { it != '#' }?.equals(' ') ==
263               true) { // not isHeader() because <h> is handled separately
264         // ## Header
265         newParagraph(i - 1).block = true
266         appendText(lineWithoutIndentation)
267         newParagraph(i).block = true
268       } else if (lineWithoutIndentation.startsWith("*") &&
269           lineWithoutIndentation.containsOnly('*', ' ')) {
270         // Horizontal rule:
271         // *******
272         // * * *
273         // Unlike --- lines, these aren't required to be preceded by or followed by
274         // blank lines.
275         newParagraph(i - 1).block = true
276         appendText(lineWithoutIndentation)
277         newParagraph(i).block = true
278       } else if (lineWithoutIndentation.startsWith("```")) {
279         i = addPreformatted(i - 1, expectClose = true) { it.trimStart().startsWith("```") }
280       } else if (lineWithoutIndentation.startsWith("<pre>", ignoreCase = true)) {
281         i =
282             addPreformatted(
283                 i - 1,
284                 includeStart = true,
285                 expectClose = true,
286                 customize = { _, _ ->
287                   if (options.convertMarkup) {
288                     fun handleTag(tag: String) {
289                       val text = paragraph.text
290                       val trimmed = text.trim()
291 
292                       val index = text.indexOf(tag, ignoreCase = true)
293                       if (index == -1) {
294                         return
295                       }
296                       paragraph.content.clear()
297                       if (trimmed.equals(tag, ignoreCase = true)) {
298                         paragraph.content.append("```")
299                         return
300                       }
301 
302                       // Split paragraphs; these things have to be on their own line
303                       // in the ``` form (unless both are in the middle)
304                       val before = text.substring(0, index).replace("</code>", "", true).trim()
305                       if (before.isNotBlank()) {
306                         paragraph.content.append(before)
307                         newParagraph()
308                         paragraph.preformatted = true
309                         paragraph.allowEmpty = true
310                       }
311                       appendText("```")
312                       val after =
313                           text.substring(index + tag.length).replace("<code>", "", true).trim()
314                       if (after.isNotBlank()) {
315                         newParagraph()
316                         appendText(after)
317                         paragraph.preformatted = true
318                         paragraph.allowEmpty = true
319                       }
320                     }
321 
322                     handleTag("<pre>")
323                     handleTag("</pre>")
324                   }
325                 },
326                 until = { it.contains("</pre>", ignoreCase = true) })
327       } else if (lineWithoutIndentation.isQuoted()) {
328         i--
329         val paragraph = newParagraph(i)
330         paragraph.quoted = true
331         paragraph.block = false
332         i =
333             addLines(
334                 i,
335                 until = { _, w, _ ->
336                   w.isBlank() ||
337                       w.isListItem() ||
338                       w.isKDocTag() ||
339                       w.isTodo() ||
340                       w.isDirectiveMarker() ||
341                       w.isHeader()
342                 },
343                 customize = { _, p -> p.quoted = true },
344                 includeEnd = false)
345         newParagraph(i)
346       } else if (lineWithoutIndentation.equals("<ul>", true) ||
347           lineWithoutIndentation.equals("<ol>", true)) {
348         newParagraph(i - 1).block = true
349         appendText(lineWithoutIndentation)
350         newParagraph(i).hanging = true
351         i =
352             addLines(
353                 i,
354                 includeEnd = true,
355                 until = { _, w, _ -> w.equals("</ul>", true) || w.equals("</ol>", true) },
356                 customize = { _, p -> p.block = true },
357                 shouldBreak = { w, _ ->
358                   w.startsWith("<li>", true) ||
359                       w.startsWith("</ul>", true) ||
360                       w.startsWith("</ol>", true)
361                 })
362         newParagraph(i)
363       } else if (lineWithoutIndentation.isListItem() ||
364           lineWithoutIndentation.isKDocTag() && task.type == CommentType.KDOC ||
365           lineWithoutIndentation.isTodo()) {
366         i--
367         newParagraph(i).hanging = true
368         val start = i
369         i =
370             addLines(
371                 i,
372                 includeEnd = false,
373                 until = { j: Int, w: String, s: String ->
374                   // See if it's a line continuation
375                   if (s.isBlank() &&
376                       j < lines.size - 1 &&
377                       lineContent(lines[j + 1]).startsWith(" ")) {
378                     false
379                   } else {
380                     s.isBlank() ||
381                         w.isListItem() ||
382                         w.isQuoted() ||
383                         w.isKDocTag() ||
384                         w.isTodo() ||
385                         s.startsWith("```") ||
386                         w.startsWith("<pre>") ||
387                         w.isDirectiveMarker() ||
388                         w.isLine() ||
389                         w.isHeader() ||
390                         // Not indented by at least two spaces following a blank line?
391                         s.length > 2 &&
392                             (!s[0].isWhitespace() || !s[1].isWhitespace()) &&
393                             j < lines.size - 1 &&
394                             lineContent(lines[j - 1]).isBlank()
395                   }
396                 },
397                 shouldBreak = { w, _ -> w.isBlank() },
398                 customize = { j, p ->
399                   if (lineContent(lines[j]).isBlank() && j >= start) {
400                     p.hanging = true
401                     p.continuation = true
402                   }
403                 })
404         newParagraph(i)
405       } else if (lineWithoutIndentation.isEmpty()) {
406         newParagraph(i).separate = true
407       } else if (lineWithoutIndentation.isDirectiveMarker()) {
408         newParagraph(i - 1)
409         appendText(lineWithoutIndentation)
410         newParagraph(i).block = true
411       } else {
412         if (lineWithoutIndentation.indexOf('|') != -1 &&
413             paragraph.isEmpty() &&
414             (i < 2 || !lines[i - 2].contains("---"))) {
415           val result = Table.getTable(lines, i - 1, ::lineContent)
416           if (result != null) {
417             val (table, nextRow) = result
418             val content =
419                 if (options.alignTableColumns) {
420                   // Only considering maxLineWidth here, not maxCommentWidth; we
421                   // cannot break table lines, only adjust tabbing, and a padded table
422                   // seems more readable (maxCommentWidth < maxLineWidth is there to
423                   // prevent long lines for readability)
424                   table.format(options.maxLineWidth - indentSize - 3)
425                 } else {
426                   table.original()
427                 }
428             for (index in content.indices) {
429               val line = content[index]
430               appendText(line)
431               paragraph.separate = index == 0
432               paragraph.block = true
433               paragraph.table = true
434               newParagraph(-1)
435             }
436             i = nextRow
437             newParagraph(i)
438             continue
439           }
440         }
441 
442         // Some common HTML block tags
443         if (lineWithoutIndentation.startsWith("<") &&
444             (lineWithoutIndentation.startsWith("<p>", true) ||
445                 lineWithoutIndentation.startsWith("<p/>", true) ||
446                 lineWithoutIndentation.startsWith("<h1", true) ||
447                 lineWithoutIndentation.startsWith("<h2", true) ||
448                 lineWithoutIndentation.startsWith("<h3", true) ||
449                 lineWithoutIndentation.startsWith("<h4", true) ||
450                 lineWithoutIndentation.startsWith("<table", true) ||
451                 lineWithoutIndentation.startsWith("<tr", true) ||
452                 lineWithoutIndentation.startsWith("<caption", true) ||
453                 lineWithoutIndentation.startsWith("<td", true) ||
454                 lineWithoutIndentation.startsWith("<div", true))) {
455           newParagraph(i - 1).block = true
456           if (lineWithoutIndentation.equals("<p>", true) ||
457               lineWithoutIndentation.equals("<p/>", true) ||
458               options.convertMarkup && lineWithoutIndentation.equals("</p>", true)) {
459             if (options.convertMarkup) {
460               // Replace <p> with a blank line
461               paragraph.separate = true
462             } else {
463               appendText(lineWithoutIndentation)
464               newParagraph(i).block = true
465             }
466             continue
467           } else if (lineWithoutIndentation.endsWith("</h1>", true) ||
468               lineWithoutIndentation.endsWith("</h2>", true) ||
469               lineWithoutIndentation.endsWith("</h3>", true) ||
470               lineWithoutIndentation.endsWith("</h4>", true)) {
471             if (lineWithoutIndentation.startsWith("<h", true) &&
472                 options.convertMarkup &&
473                 paragraph.isEmpty()) {
474               paragraph.separate = true
475               val count = lineWithoutIndentation[lineWithoutIndentation.length - 2] - '0'
476               for (j in 0 until count.coerceAtLeast(0).coerceAtMost(8)) {
477                 appendText("#")
478               }
479               appendText(" ")
480               appendText(lineWithoutIndentation.substring(4, lineWithoutIndentation.length - 5))
481             } else if (options.collapseSpaces) {
482               appendText(lineWithoutIndentation.collapseSpaces())
483             } else {
484               appendText(lineWithoutIndentation)
485             }
486             newParagraph(i).block = true
487             continue
488           }
489         }
490 
491         i = addPlainText(i, lineWithoutIndentation)
492       }
493     }
494 
495     closeParagraph()
496     arrange()
497     if (!lineComment) {
498       punctuate()
499     }
500 
501     return ParagraphList(paragraphs)
502   }
503 
504   private fun convertPrefix(text: String): String {
505     return if (options.convertMarkup &&
506         (text.startsWith("<p>", true) || text.startsWith("<p/>", true))) {
507       paragraph.separate = true
508       text.substring(text.indexOf('>') + 1).trim()
509     } else {
510       text
511     }
512   }
513 
514   private fun convertSuffix(trimmedPrefix: String): String {
515     return if (options.convertMarkup &&
516         (trimmedPrefix.endsWith("<p/>", true) || (trimmedPrefix.endsWith("</p>", true)))) {
517       trimmedPrefix.substring(0, trimmedPrefix.length - 4).trimEnd().removeSuffix("*").trimEnd()
518     } else {
519       trimmedPrefix
520     }
521   }
522 
523   private fun addPlainText(i: Int, text: String, braceBalance: Int = 0): Int {
524     val trimmed = convertSuffix(convertPrefix(text))
525     val s = trimmed.let { if (options.collapseSpaces) it.collapseSpaces() else it }
526     appendText(s)
527     appendText(" ")
528 
529     if (braceBalance > 0) {
530       val end = s.indexOf('}')
531       if (end == -1 && i < lines.size) {
532         val next = lineContent(lines[i]).trim()
533         if (breakOutOfTag(next)) {
534           return i
535         }
536         return addPlainText(i + 1, next, 1)
537       }
538     }
539 
540     val index = s.indexOf("{@")
541     if (index != -1) {
542       // find end
543       val end = s.indexOf('}', index)
544       if (end == -1 && i < lines.size) {
545         val next = lineContent(lines[i]).trim()
546         if (breakOutOfTag(next)) {
547           return i
548         }
549         return addPlainText(i + 1, next, 1)
550       }
551     }
552 
553     return i
554   }
555 
556   private fun breakOutOfTag(next: String): Boolean {
557     if (next.isBlank() || next.startsWith("```")) {
558       // See https://github.com/tnorbye/kdoc-formatter/issues/77
559       // There may be comments which look unusual from a formatting
560       // perspective where it looks like you have embedded markup
561       // or blank lines; if so, just give up on trying to turn
562       // this into paragraph text
563       return true
564     }
565     return false
566   }
567 
568   private fun docTagRank(tag: String, isPriority: Boolean): Int {
569     // Canonical kdoc order -- https://kotlinlang.org/docs/kotlin-doc.html#block-tags
570     // Full list in Dokka's sources: plugins/base/src/main/kotlin/parsers/Parser.kt
571     return when {
572       isPriority -> -1
573       tag.startsWith("@param") -> 0
574       tag.startsWith("@property") -> 0
575       // @param and @property must be sorted by parameter order
576       // a @property is dedicated syntax for a main constructor @param that also sets a class
577       // property
578       tag.startsWith("@return") -> 1
579       tag.startsWith("@constructor") -> 2
580       tag.startsWith("@receiver") -> 3
581       tag.startsWith("@throws") -> 4
582       tag.startsWith("@exception") -> 5
583       tag.startsWith("@sample") -> 6
584       tag.startsWith("@see") -> 7
585       tag.startsWith("@author") -> 8
586       tag.startsWith("@since") -> 9
587       tag.startsWith("@suppress") -> 10
588       tag.startsWith("@deprecated") -> 11
589       else -> 100 // custom tags
590     }
591   }
592 
593   /**
594    * Tags that are "priority" are placed before other tags, with their order unchanged.
595    *
596    * Note that if a priority tag comes after a regular tag (before formatting), it doesn't get
597    * treated as priority.
598    *
599    * See: https://github.com/facebook/ktfmt/issues/406
600    */
601   private fun docTagIsPriority(tag: String): Boolean {
602     return when {
603       tag.startsWith("@sample") -> true
604       else -> false
605     }
606   }
607 
608   /**
609    * Make a pass over the paragraphs and make sure that we (for example) place blank lines around
610    * preformatted text.
611    */
612   private fun arrange() {
613     if (paragraphs.isEmpty()) {
614       return
615     }
616 
617     sortDocTags()
618     adjustParagraphSeparators()
619     adjustIndentation()
620     removeBlankParagraphs()
621     stripTrailingBlankLines()
622   }
623 
624   private fun sortDocTags() {
625     if (options.orderDocTags && paragraphs.any { it.doc }) {
626       val order = paragraphs.mapIndexed { index, paragraph -> paragraph to index }.toMap()
627       val firstNonPriorityDocTag = paragraphs.indexOfFirst { it.doc && !docTagIsPriority(it.text) }
628       val comparator =
629           object : Comparator<List<Paragraph>> {
630             override fun compare(l1: List<Paragraph>, l2: List<Paragraph>): Int {
631               val p1 = l1.first()
632               val p2 = l2.first()
633               val o1 = order[p1]!!
634               val o2 = order[p2]!!
635               val isPriority1 = p1.doc && docTagIsPriority(p1.text) && o1 < firstNonPriorityDocTag
636               val isPriority2 = p2.doc && docTagIsPriority(p2.text) && o2 < firstNonPriorityDocTag
637 
638               // Sort TODOs to the end
639               if (p1.text.isTodo() != p2.text.isTodo()) {
640                 return if (p1.text.isTodo()) 1 else -1
641               }
642 
643               if (p1.doc == p2.doc) {
644                 if (p1.doc) {
645                   // Sort @return after @param etc
646                   val r1 = docTagRank(p1.text, isPriority1)
647                   val r2 = docTagRank(p2.text, isPriority2)
648                   if (r1 != r2) {
649                     return r1 - r2
650                   }
651                   // Within identical tags, preserve current order, except for
652                   // parameter names which are sorted by signature order.
653                   val orderedParameterNames = task.orderedParameterNames
654                   if (orderedParameterNames.isNotEmpty()) {
655                     fun Paragraph.parameterRank(): Int {
656                       val name = text.getParamName()
657                       if (name != null) {
658                         val index = orderedParameterNames.indexOf(name)
659                         if (index != -1) {
660                           return index
661                         }
662                       }
663                       return 1000
664                     }
665 
666                     val i1 = p1.parameterRank()
667                     val i2 = p2.parameterRank()
668 
669                     // If the parameter names are not matching, ignore.
670                     if (i1 != i2) {
671                       return i1 - i2
672                     }
673                   }
674                 }
675                 return o1 - o2
676               }
677               return if (p1.doc) 1 else -1
678             }
679           }
680 
681       // We don't sort the paragraphs list directly; we have to tie all the
682       // paragraphs following a KDoc parameter to that paragraph (until the
683       // next KDoc tag). So instead we create a list of lists -- consisting of
684       // one list for each paragraph, though with a KDoc parameter it's a list
685       // containing first the KDoc parameter paragraph and then all following
686       // parameters. We then sort by just the first item in this list of list,
687       // and then restore the paragraph list from the result.
688       val units = mutableListOf<List<Paragraph>>()
689       var tag: MutableList<Paragraph>? = null
690       for (paragraph in paragraphs) {
691         if (paragraph.doc) {
692           tag = mutableListOf()
693           units.add(tag)
694         }
695         if (tag != null && !paragraph.text.isTodo()) {
696           tag.add(paragraph)
697         } else {
698           units.add(listOf(paragraph))
699         }
700       }
701       units.sortWith(comparator)
702 
703       var prev: Paragraph? = null
704       paragraphs.clear()
705       for (paragraph in units.flatten()) {
706         paragraphs.add(paragraph)
707         prev?.next = paragraph
708         paragraph.prev = prev
709         prev = paragraph
710       }
711     }
712   }
713 
714   private fun adjustParagraphSeparators() {
715     var prev: Paragraph? = null
716 
717     for (paragraph in paragraphs) {
718       paragraph.cleanup()
719       val text = paragraph.text
720       paragraph.separate =
721           when {
722             prev == null -> false
723             paragraph.preformatted && prev.preformatted -> false
724             paragraph.table ->
725                 paragraph.separate && (!prev.block || prev.text.isKDocTag() || prev.table)
726             paragraph.separator || prev.separator -> true
727             text.isLine(1) || prev.text.isLine(1) -> false
728             paragraph.separate && paragraph.text.isListItem() -> false
729             paragraph.separate -> true
730             // Don't separate kdoc tags, except for the first one
731             paragraph.doc -> !prev.doc
732             text.isDirectiveMarker() -> false
733             text.isTodo() && !prev.text.isTodo() -> true
734             text.isHeader() -> true
735             // Set preformatted paragraphs off (but not <pre> tags where it's implicit)
736             paragraph.preformatted ->
737                 !prev.preformatted &&
738                     !text.startsWith("<pre", true) &&
739                     (!text.startsWith("```") || !prev.text.isExpectingMore())
740             prev.preformatted && prev.text.startsWith("</pre>", true) -> false
741             paragraph.continuation -> true
742             paragraph.hanging -> false
743             paragraph.quoted -> prev.quoted
744             text.isHeader() -> true
745             text.startsWith("<p>", true) || text.startsWith("<p/>", true) -> true
746             else -> !paragraph.block && !paragraph.isEmpty()
747           }
748 
749       if (paragraph.hanging) {
750         if (paragraph.doc || text.startsWith("<li>", true) || text.isTodo()) {
751           paragraph.hangingIndent = getIndent(options.hangingIndent)
752         } else if (paragraph.continuation && paragraph.prev != null) {
753           paragraph.hangingIndent = paragraph.prev!!.hangingIndent
754           // Dedent to match hanging indent
755           val s = paragraph.text.trimStart()
756           paragraph.content.clear()
757           paragraph.content.append(s)
758         } else {
759           paragraph.hangingIndent = getIndent(text.indexOf(' ') + 1)
760         }
761       }
762       prev = paragraph
763     }
764   }
765 
766   private fun adjustIndentation() {
767     val firstIndent = paragraphs[0].originalIndent
768     if (firstIndent > 0) {
769       for (paragraph in paragraphs) {
770         if (paragraph.originalIndent <= firstIndent) {
771           paragraph.originalIndent = 0
772         }
773       }
774     }
775 
776     // Handle nested lists
777     var inList = paragraphs.firstOrNull()?.hanging ?: false
778     var startIndent = 0
779     var levels: MutableSet<Int>? = null
780     for (i in 1 until paragraphs.size) {
781       val paragraph = paragraphs[i]
782       if (!inList) {
783         if (paragraph.hanging) {
784           inList = true
785           startIndent = paragraph.originalIndent
786         }
787       } else {
788         if (!paragraph.hanging) {
789           inList = false
790         } else {
791           if (paragraph.originalIndent == startIndent) {
792             paragraph.originalIndent = 0
793           } else if (paragraph.originalIndent > 0) {
794             (levels ?: mutableSetOf<Int>().also { levels = it }).add(paragraph.originalIndent)
795           }
796         }
797       }
798     }
799 
800     levels?.sorted()?.let { sorted ->
801       val assignments = mutableMapOf<Int, Int>()
802       for (i in sorted.indices) {
803         assignments[sorted[i]] = (i + 1) * options.nestedListIndent
804       }
805       for (paragraph in paragraphs) {
806         if (paragraph.originalIndent > 0) {
807           val assigned = assignments[paragraph.originalIndent] ?: continue
808           paragraph.originalIndent = assigned
809           paragraph.indent = getIndent(paragraph.originalIndent)
810         }
811       }
812     }
813   }
814 
815   private fun removeBlankParagraphs() {
816     // Remove blank lines between list items and from the end as well as around
817     // separators
818     for (i in paragraphs.size - 2 downTo 0) {
819       if (paragraphs[i].isEmpty() && (!paragraphs[i].preformatted || i == paragraphs.size - 1)) {
820         paragraphs.removeAt(i)
821         if (i > 0) {
822           paragraphs[i - 1].next = null
823         }
824       }
825     }
826   }
827 
828   private fun punctuate() {
829     if (!options.addPunctuation || paragraphs.isEmpty()) {
830       return
831     }
832     val last = paragraphs.last()
833     if (last.preformatted || last.doc || last.hanging && !last.continuation || last.isEmpty()) {
834       return
835     }
836 
837     val text = last.content
838     if (!text.startsWithUpperCaseLetter()) {
839       return
840     }
841 
842     for (i in text.length - 1 downTo 0) {
843       val c = text[i]
844       if (c.isWhitespace()) {
845         continue
846       }
847       if (c.isLetterOrDigit() || c.isCloseSquareBracket()) {
848         text.setLength(i + 1)
849         text.append('.')
850       }
851       break
852     }
853   }
854 }
855 
containsOnlynull856 fun String.containsOnly(vararg s: Char): Boolean {
857   for (c in this) {
858     if (s.none { it == c }) {
859       return false
860     }
861   }
862   return true
863 }
864 
StringBuildernull865 fun StringBuilder.startsWithUpperCaseLetter() =
866     this.isNotEmpty() && this[0].isUpperCase() && this[0].isLetter()
867 
868 fun Char.isCloseSquareBracket() = this == ']'
869