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