• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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): 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       tag.startsWith("@param") -> 0
573       tag.startsWith("@return") -> 1
574       tag.startsWith("@constructor") -> 2
575       tag.startsWith("@receiver") -> 3
576       tag.startsWith("@property") -> 4
577       tag.startsWith("@throws") -> 5
578       tag.startsWith("@exception") -> 6
579       tag.startsWith("@sample") -> 7
580       tag.startsWith("@see") -> 8
581       tag.startsWith("@author") -> 9
582       tag.startsWith("@since") -> 10
583       tag.startsWith("@suppress") -> 11
584       tag.startsWith("@deprecated") -> 12
585       else -> 100 // custom tags
586     }
587   }
588 
589   /**
590    * Make a pass over the paragraphs and make sure that we (for example) place blank lines around
591    * preformatted text.
592    */
593   private fun arrange() {
594     if (paragraphs.isEmpty()) {
595       return
596     }
597 
598     sortDocTags()
599     adjustParagraphSeparators()
600     adjustIndentation()
601     removeBlankParagraphs()
602     stripTrailingBlankLines()
603   }
604 
605   private fun sortDocTags() {
606     if (options.orderDocTags && paragraphs.any { it.doc }) {
607       val order = paragraphs.mapIndexed { index, paragraph -> paragraph to index }.toMap()
608       val comparator =
609           object : Comparator<List<Paragraph>> {
610             override fun compare(l1: List<Paragraph>, l2: List<Paragraph>): Int {
611               val p1 = l1.first()
612               val p2 = l2.first()
613               val o1 = order[p1]!!
614               val o2 = order[p2]!!
615 
616               // Sort TODOs to the end
617               if (p1.text.isTodo() != p2.text.isTodo()) {
618                 return if (p1.text.isTodo()) 1 else -1
619               }
620 
621               if (p1.doc == p2.doc) {
622                 if (p1.doc) {
623                   // Sort @return after @param etc
624                   val r1 = docTagRank(p1.text)
625                   val r2 = docTagRank(p2.text)
626                   if (r1 != r2) {
627                     return r1 - r2
628                   }
629                   // Within identical tags, preserve current order, except for
630                   // parameter names which are sorted by signature order.
631                   val orderedParameterNames = task.orderedParameterNames
632                   if (orderedParameterNames.isNotEmpty()) {
633                     fun Paragraph.parameterRank(): Int {
634                       val name = text.getParamName()
635                       if (name != null) {
636                         val index = orderedParameterNames.indexOf(name)
637                         if (index != -1) {
638                           return index
639                         }
640                       }
641                       return 1000
642                     }
643 
644                     val i1 = p1.parameterRank()
645                     val i2 = p2.parameterRank()
646 
647                     // If the parameter names are not matching, ignore.
648                     if (i1 != i2) {
649                       return i1 - i2
650                     }
651                   }
652                 }
653                 return o1 - o2
654               }
655               return if (p1.doc) 1 else -1
656             }
657           }
658 
659       // We don't sort the paragraphs list directly; we have to tie all the
660       // paragraphs following a KDoc parameter to that paragraph (until the
661       // next KDoc tag). So instead we create a list of lists -- consisting of
662       // one list for each paragraph, though with a KDoc parameter it's a list
663       // containing first the KDoc parameter paragraph and then all following
664       // parameters. We then sort by just the first item in this list of list,
665       // and then restore the paragraph list from the result.
666       val units = mutableListOf<List<Paragraph>>()
667       var tag: MutableList<Paragraph>? = null
668       for (paragraph in paragraphs) {
669         if (paragraph.doc) {
670           tag = mutableListOf()
671           units.add(tag)
672         }
673         if (tag != null && !paragraph.text.isTodo()) {
674           tag.add(paragraph)
675         } else {
676           units.add(listOf(paragraph))
677         }
678       }
679       units.sortWith(comparator)
680 
681       var prev: Paragraph? = null
682       paragraphs.clear()
683       for (paragraph in units.flatten()) {
684         paragraphs.add(paragraph)
685         prev?.next = paragraph
686         paragraph.prev = prev
687         prev = paragraph
688       }
689     }
690   }
691 
692   private fun adjustParagraphSeparators() {
693     var prev: Paragraph? = null
694 
695     for (paragraph in paragraphs) {
696       paragraph.cleanup()
697       val text = paragraph.text
698       paragraph.separate =
699           when {
700             prev == null -> false
701             paragraph.preformatted && prev.preformatted -> false
702             paragraph.table ->
703                 paragraph.separate && (!prev.block || prev.text.isKDocTag() || prev.table)
704             paragraph.separator || prev.separator -> true
705             text.isLine(1) || prev.text.isLine(1) -> false
706             paragraph.separate && paragraph.text.isListItem() -> false
707             paragraph.separate -> true
708             // Don't separate kdoc tags, except for the first one
709             paragraph.doc -> !prev.doc
710             text.isDirectiveMarker() -> false
711             text.isTodo() && !prev.text.isTodo() -> true
712             text.isHeader() -> true
713             // Set preformatted paragraphs off (but not <pre> tags where it's implicit)
714             paragraph.preformatted ->
715                 !prev.preformatted &&
716                     !text.startsWith("<pre", true) &&
717                     (!text.startsWith("```") || !prev.text.isExpectingMore())
718             prev.preformatted && prev.text.startsWith("</pre>", true) -> false
719             paragraph.continuation -> true
720             paragraph.hanging -> false
721             paragraph.quoted -> prev.quoted
722             text.isHeader() -> true
723             text.startsWith("<p>", true) || text.startsWith("<p/>", true) -> true
724             else -> !paragraph.block && !paragraph.isEmpty()
725           }
726 
727       if (paragraph.hanging) {
728         if (paragraph.doc || text.startsWith("<li>", true) || text.isTodo()) {
729           paragraph.hangingIndent = getIndent(options.hangingIndent)
730         } else if (paragraph.continuation && paragraph.prev != null) {
731           paragraph.hangingIndent = paragraph.prev!!.hangingIndent
732           // Dedent to match hanging indent
733           val s = paragraph.text.trimStart()
734           paragraph.content.clear()
735           paragraph.content.append(s)
736         } else {
737           paragraph.hangingIndent = getIndent(text.indexOf(' ') + 1)
738         }
739       }
740       prev = paragraph
741     }
742   }
743 
744   private fun adjustIndentation() {
745     val firstIndent = paragraphs[0].originalIndent
746     if (firstIndent > 0) {
747       for (paragraph in paragraphs) {
748         if (paragraph.originalIndent <= firstIndent) {
749           paragraph.originalIndent = 0
750         }
751       }
752     }
753 
754     // Handle nested lists
755     var inList = paragraphs.firstOrNull()?.hanging ?: false
756     var startIndent = 0
757     var levels: MutableSet<Int>? = null
758     for (i in 1 until paragraphs.size) {
759       val paragraph = paragraphs[i]
760       if (!inList) {
761         if (paragraph.hanging) {
762           inList = true
763           startIndent = paragraph.originalIndent
764         }
765       } else {
766         if (!paragraph.hanging) {
767           inList = false
768         } else {
769           if (paragraph.originalIndent == startIndent) {
770             paragraph.originalIndent = 0
771           } else if (paragraph.originalIndent > 0) {
772             (levels ?: mutableSetOf<Int>().also { levels = it }).add(paragraph.originalIndent)
773           }
774         }
775       }
776     }
777 
778     levels?.sorted()?.let { sorted ->
779       val assignments = mutableMapOf<Int, Int>()
780       for (i in sorted.indices) {
781         assignments[sorted[i]] = (i + 1) * options.nestedListIndent
782       }
783       for (paragraph in paragraphs) {
784         if (paragraph.originalIndent > 0) {
785           val assigned = assignments[paragraph.originalIndent] ?: continue
786           paragraph.originalIndent = assigned
787           paragraph.indent = getIndent(paragraph.originalIndent)
788         }
789       }
790     }
791   }
792 
793   private fun removeBlankParagraphs() {
794     // Remove blank lines between list items and from the end as well as around
795     // separators
796     for (i in paragraphs.size - 2 downTo 0) {
797       if (paragraphs[i].isEmpty() && (!paragraphs[i].preformatted || i == paragraphs.size - 1)) {
798         paragraphs.removeAt(i)
799         if (i > 0) {
800           paragraphs[i - 1].next = null
801         }
802       }
803     }
804   }
805 
806   private fun punctuate() {
807     if (!options.addPunctuation || paragraphs.isEmpty()) {
808       return
809     }
810     val last = paragraphs.last()
811     if (last.preformatted || last.doc || last.hanging && !last.continuation || last.isEmpty()) {
812       return
813     }
814 
815     val text = last.content
816     if (!text.startsWithUpperCaseLetter()) {
817       return
818     }
819 
820     for (i in text.length - 1 downTo 0) {
821       val c = text[i]
822       if (c.isWhitespace()) {
823         continue
824       }
825       if (c.isLetterOrDigit() || c.isCloseSquareBracket()) {
826         text.setLength(i + 1)
827         text.append('.')
828       }
829       break
830     }
831   }
832 }
833 
containsOnlynull834 fun String.containsOnly(vararg s: Char): Boolean {
835   for (c in this) {
836     if (s.none { it == c }) {
837       return false
838     }
839   }
840   return true
841 }
842 
StringBuildernull843 fun StringBuilder.startsWithUpperCaseLetter() =
844     this.isNotEmpty() && this[0].isUpperCase() && this[0].isLetter()
845 
846 fun Char.isCloseSquareBracket() = this == ']'
847