xref: /aosp_15_r20/external/dokka/core/src/main/kotlin/Java/JavadocParser.kt (revision 1b2d298c530bf0473cc943e8414a5ff577a994bc)

<lambda>null1 package org.jetbrains.dokka
2 
3 import com.intellij.psi.*
4 import com.intellij.psi.impl.source.javadoc.PsiDocTagValueImpl
5 import com.intellij.psi.impl.source.tree.JavaDocElementType
6 import com.intellij.psi.javadoc.*
7 import com.intellij.psi.util.PsiTreeUtil
8 import com.intellij.util.IncorrectOperationException
9 import org.jetbrains.dokka.Model.CodeNode
10 import org.jetbrains.kotlin.utils.join
11 import org.jetbrains.kotlin.utils.keysToMap
12 import org.jsoup.Jsoup
13 import org.jsoup.nodes.Element
14 import org.jsoup.nodes.Node
15 import org.jsoup.nodes.TextNode
16 import java.io.File
17 import java.net.URI
18 import java.util.regex.Pattern
19 
20 private val NAME_TEXT = Pattern.compile("(\\S+)(.*)", Pattern.DOTALL)
21 private val TEXT = Pattern.compile("(\\S+)\\s*(.*)", Pattern.DOTALL)
22 
23 data class JavadocParseResult(
24     val content: Content,
25     val deprecatedContent: Content?,
26     val attributeRefs: List<String>,
27     val apiLevel: DocumentationNode? = null,
28     val sdkExtSince: DocumentationNode? = null,
29     val deprecatedLevel: DocumentationNode? = null,
30     val artifactId: DocumentationNode? = null,
31     val attribute: DocumentationNode? = null
32 ) {
33     companion object {
34         val Empty = JavadocParseResult(Content.Empty,
35             null,
36             emptyList(),
37             null,
38             null,
39             null,
40             null
41         )
42     }
43 }
44 
45 interface JavaDocumentationParser {
parseDocumentationnull46     fun parseDocumentation(element: PsiNamedElement): JavadocParseResult
47 }
48 
49 class JavadocParser(
50     private val refGraph: NodeReferenceGraph,
51     private val logger: DokkaLogger,
52     private val signatureProvider: ElementSignatureProvider,
53     private val externalDocumentationLinkResolver: ExternalDocumentationLinkResolver
54 ) : JavaDocumentationParser {
55 
56     private fun ContentSection.appendTypeElement(
57         signature: String,
58         selector: (DocumentationNode) -> DocumentationNode?
59     ) {
60         append(LazyContentBlock {
61             val node = refGraph.lookupOrWarn(signature, logger)?.let(selector)
62             if (node != null) {
63                 it.append(NodeRenderContent(node, LanguageService.RenderMode.SUMMARY))
64                 it.symbol(":")
65                 it.text(" ")
66             }
67         })
68     }
69 
70     override fun parseDocumentation(element: PsiNamedElement): JavadocParseResult {
71         val docComment = (element as? PsiDocCommentOwner)?.docComment
72         if (docComment == null) return JavadocParseResult.Empty
73         val result = MutableContent()
74         var deprecatedContent: Content? = null
75         val firstParagraph = ContentParagraph()
76         firstParagraph.convertJavadocElements(
77             docComment.descriptionElements.dropWhile { it.text.trim().isEmpty() },
78             element
79         )
80         val paragraphs = firstParagraph.children.dropWhile { it !is ContentParagraph }
81         firstParagraph.children.removeAll(paragraphs)
82         if (!firstParagraph.isEmpty()) {
83             result.append(firstParagraph)
84         }
85         paragraphs.forEach {
86             result.append(it)
87         }
88 
89         if (element is PsiMethod) {
90             val tagsByName = element.searchInheritedTags()
91             for ((tagName, tags) in tagsByName) {
92                 for ((tag, context) in tags) {
93                     val section = result.addSection(javadocSectionDisplayName(tagName), tag.getSubjectName())
94                     val signature = signatureProvider.signature(element)
95                     when (tagName) {
96                         "param" -> {
97                             section.appendTypeElement(signature) {
98                                 it.details
99                                     .find { node -> node.kind == NodeKind.Parameter && node.name == tag.getSubjectName() }
100                                     ?.detailOrNull(NodeKind.Type)
101                             }
102                         }
103                         "return" -> {
104                             section.appendTypeElement(signature) { it.detailOrNull(NodeKind.Type) }
105                         }
106                     }
107                     section.convertJavadocElements(tag.contentElements(), context)
108                 }
109             }
110         }
111 
112         val attrRefSignatures = mutableListOf<String>()
113         var since: DocumentationNode? = null
114         var sdkextsince: DocumentationNode? = null
115         var deprecated: DocumentationNode? = null
116         var artifactId: DocumentationNode? = null
117         var attrName: String? = null
118         var attrDesc: Content? = null
119         var attr: DocumentationNode? = null
120         docComment.tags.forEach { tag ->
121             when (tag.name.toLowerCase()) {
122                 "see" -> result.convertSeeTag(tag)
123                 "deprecated" -> {
124                     deprecatedContent = Content().apply {
125                         convertJavadocElements(tag.contentElements(), element)
126                     }
127                 }
128                 "attr" -> {
129                     when (tag.valueElement?.text) {
130                         "ref" ->
131                             tag.getAttrRef(element)?.let {
132                                 attrRefSignatures.add(it)
133                             }
134                         "name" -> attrName = tag.getAttrName()
135                         "description" -> attrDesc = tag.getAttrDesc(element)
136                     }
137                 }
138                 "since", "apisince" -> {
139                     since = DocumentationNode(tag.getApiLevel() ?: "", Content.Empty, NodeKind.ApiLevel)
140                 }
141                 "sdkextsince" -> {
142                     sdkextsince = DocumentationNode(tag.getSdkExtSince() ?: "", Content.Empty, NodeKind.SdkExtSince)
143                 }
144                 "deprecatedsince" -> {
145                     deprecated = DocumentationNode(tag.getApiLevel() ?: "", Content.Empty, NodeKind.DeprecatedLevel)
146                 }
147                 "artifactid" -> {
148                     artifactId = DocumentationNode(tag.artifactId() ?: "", Content.Empty, NodeKind.ArtifactId)
149                 }
150                 in tagsToInherit -> {
151                 }
152                 else -> {
153                     val subjectName = tag.getSubjectName()
154                     val section = result.addSection(javadocSectionDisplayName(tag.name), subjectName)
155                     section.convertJavadocElements(tag.contentElements(), element)
156                 }
157             }
158         }
159         attrName?.let { name ->
160             attr = DocumentationNode(name, attrDesc ?: Content.Empty, NodeKind.AttributeRef)
161         }
162         return JavadocParseResult(result, deprecatedContent, attrRefSignatures, since, sdkextsince, deprecated, artifactId, attr)
163     }
164 
165     private val tagsToInherit = setOf("param", "return", "throws")
166 
167     private data class TagWithContext(val tag: PsiDocTag, val context: PsiNamedElement)
168 
169     fun PsiDocTag.artifactId(): String? {
170         var artifactName: String? = null
171         if (dataElements.isNotEmpty()) {
172             artifactName = join(dataElements.map { it.text }, "")
173         }
174         return artifactName
175     }
176 
177     fun PsiDocTag.getApiLevel(): String? {
178         if (dataElements.isNotEmpty()) {
179             val data = dataElements
180             if (data[0] is PsiDocTagValueImpl) {
181                 val docTagValue = data[0]
182                 if (docTagValue.firstChild != null) {
183                     val apiLevel = docTagValue.firstChild
184                     return apiLevel.text
185                 }
186             }
187         }
188         return null
189     }
190 
191     fun PsiDocTag.getSdkExtSince(): String? {
192         if (dataElements.isNotEmpty()) {
193             return join(dataElements.map { it.text }, " ")
194         }
195         return null
196     }
197 
198     private fun PsiDocTag.getAttrRef(element: PsiNamedElement): String? {
199         if (dataElements.size > 1) {
200             val elementText = dataElements[1].text
201             try {
202                 val linkComment = JavaPsiFacade.getInstance(project).elementFactory
203                     .createDocCommentFromText("/** {@link $elementText} */", element)
204                 val linkElement = PsiTreeUtil.getChildOfType(linkComment, PsiInlineDocTag::class.java)?.linkElement()
205                 val signature = resolveInternalLink(linkElement)
206                 val attrSignature = "AttrMain:$signature"
207                 return attrSignature
208             } catch (e: IncorrectOperationException) {
209                 return null
210             }
211         } else return null
212     }
213 
214     private fun PsiDocTag.getAttrName(): String? {
215         if (dataElements.size > 1) {
216             val nameMatcher = NAME_TEXT.matcher(dataElements[1].text)
217             if (nameMatcher.matches()) {
218                 return nameMatcher.group(1)
219             } else {
220                 return null
221             }
222         } else return null
223     }
224 
225     private fun PsiDocTag.getAttrDesc(element: PsiNamedElement): Content? {
226         return Content().apply {
227             convertJavadocElementsToAttrDesc(contentElements(), element)
228         }
229     }
230 
231     private fun PsiMethod.searchInheritedTags(): Map<String, Collection<TagWithContext>> {
232 
233         val output = tagsToInherit.keysToMap { mutableMapOf<String?, TagWithContext>() }
234 
235         fun recursiveSearch(methods: Array<PsiMethod>) {
236             for (method in methods) {
237                 recursiveSearch(method.findSuperMethods())
238             }
239             for (method in methods) {
240                 for (tag in method.docComment?.tags.orEmpty()) {
241                     if (tag.name in tagsToInherit) {
242                         output[tag.name]!![tag.getSubjectName()] = TagWithContext(tag, method)
243                     }
244                 }
245             }
246         }
247 
248         recursiveSearch(arrayOf(this))
249         return output.mapValues { it.value.values }
250     }
251 
252 
253     private fun PsiDocTag.contentElements(): Iterable<PsiElement> {
254         val tagValueElements = children
255             .dropWhile { it.node?.elementType == JavaDocTokenType.DOC_TAG_NAME }
256             .dropWhile { it is PsiWhiteSpace }
257             .filterNot { it.node?.elementType == JavaDocTokenType.DOC_COMMENT_LEADING_ASTERISKS }
258         return if (getSubjectName() != null) tagValueElements.dropWhile { it is PsiDocTagValue } else tagValueElements
259     }
260 
261     private fun ContentBlock.convertJavadocElements(elements: Iterable<PsiElement>, element: PsiNamedElement) {
262         val doc = Jsoup.parse(expandAllForElements(elements, element))
263         doc.body().childNodes().forEach {
264             convertHtmlNode(it)?.let { append(it) }
265         }
266         doc.head().childNodes().forEach {
267             convertHtmlNode(it)?.let { append(it) }
268         }
269     }
270 
271     private fun ContentBlock.convertJavadocElementsToAttrDesc(elements: Iterable<PsiElement>, element: PsiNamedElement) {
272         val doc = Jsoup.parse(expandAllForElements(elements, element))
273         doc.body().childNodes().forEach {
274             convertHtmlNode(it)?.let {
275                 var content = it
276                 if (content is ContentText) {
277                     var description = content.text
278                     val matcher = TEXT.matcher(content.text)
279                     if (matcher.matches()) {
280                         val command = matcher.group(1)
281                         if (command == "description") {
282                             description = matcher.group(2)
283                             content = ContentText(description)
284                         }
285                     }
286                 }
287                 append(content)
288             }
289         }
290     }
291 
292     private fun expandAllForElements(elements: Iterable<PsiElement>, element: PsiNamedElement): String {
293         val htmlBuilder = StringBuilder()
294         elements.forEach {
295             if (it is PsiInlineDocTag) {
296                 htmlBuilder.append(convertInlineDocTag(it, element))
297             } else {
298                 htmlBuilder.append(it.text)
299             }
300         }
301         return htmlBuilder.toString().trim()
302     }
303 
304     private fun convertHtmlNode(node: Node, isBlockCode: Boolean = false): ContentNode? {
305         if (isBlockCode) {
306             return if (node is TextNode) { // Fixes b/129762453
307                 val codeNode = CodeNode(node.wholeText, "")
308                 ContentText(codeNode.text().removePrefix("#"))
309             } else { // Fixes b/129857975
310                 ContentText(node.toString())
311             }
312         }
313         if (node is TextNode) {
314             return ContentText(node.text().removePrefix("#"))
315         } else if (node is Element) {
316             val childBlock = createBlock(node)
317             node.childNodes().forEach {
318                 val child = convertHtmlNode(it, isBlockCode = childBlock is ContentBlockCode)
319                 if (child != null) {
320                     childBlock.append(child)
321                 }
322             }
323             return (childBlock)
324         }
325         return null
326     }
327 
328     private fun createBlock(element: Element): ContentBlock = when (element.tagName()) {
329         "p" -> ContentParagraph()
330         "b", "strong" -> ContentStrong()
331         "i", "em" -> ContentEmphasis()
332         "s", "del" -> ContentStrikethrough()
333         "code" -> ContentCode()
334         "pre" -> ContentBlockCode()
335         "ul" -> ContentUnorderedList()
336         "ol" -> ContentOrderedList()
337         "li" -> ContentListItem()
338         "a" -> createLink(element)
339         "br" -> ContentBlock().apply { hardLineBreak() }
340 
341         "dl" -> ContentDescriptionList()
342         "dt" -> ContentDescriptionTerm()
343         "dd" -> ContentDescriptionDefinition()
344 
345         "table" -> ContentTable()
346         "tbody" -> ContentTableBody()
347         "tr" -> ContentTableRow()
348         "th" -> {
349             val colspan = element.attr("colspan")
350             val rowspan = element.attr("rowspan")
351             ContentTableHeader(colspan, rowspan)
352         }
353         "td" -> {
354             val colspan = element.attr("colspan")
355             val rowspan = element.attr("rowspan")
356             ContentTableCell(colspan, rowspan)
357         }
358 
359         "h1" -> ContentHeading(1)
360         "h2" -> ContentHeading(2)
361         "h3" -> ContentHeading(3)
362         "h4" -> ContentHeading(4)
363         "h5" -> ContentHeading(5)
364         "h6" -> ContentHeading(6)
365 
366         "div" -> {
367             val divClass = element.attr("class")
368             if (divClass == "special reference" || divClass == "note") ContentSpecialReference()
369             else ContentParagraph()
370         }
371 
372         "script" -> {
373 
374             // If the `type` attr is an empty string, we want to use null instead so that the resulting generated
375             // Javascript does not contain a `type` attr.
376             //
377             // Example:
378             // type == ""   => <script type="" src="...">
379             // type == null => <script src="...">
380             val type = if (element.attr("type").isNotEmpty()) {
381                 element.attr("type")
382             } else {
383                 null
384             }
385             ScriptBlock(type, element.attr("src"))
386         }
387 
388         else -> ContentBlock()
389     }
390 
391     private fun createLink(element: Element): ContentBlock {
392         return when {
393             element.hasAttr("docref") -> {
394                 val docref = element.attr("docref")
395                 ContentNodeLazyLink(docref, { -> refGraph.lookupOrWarn(docref, logger) })
396             }
397             element.hasAttr("href") -> {
398                 val href = element.attr("href")
399 
400                 val uri = try {
401                     URI(href)
402                 } catch (_: Exception) {
403                     null
404                 }
405 
406                 if (uri?.isAbsolute == false) {
407                     ContentLocalLink(href)
408                 } else {
409                     ContentExternalLink(href)
410                 }
411             }
412             element.hasAttr("name") -> {
413                 ContentBookmark(element.attr("name"))
414             }
415             else -> ContentBlock()
416         }
417     }
418 
419     private fun MutableContent.convertSeeTag(tag: PsiDocTag) {
420         val linkElement = tag.linkElement() ?: return
421         val seeSection = findSectionByTag(ContentTags.SeeAlso) ?: addSection(ContentTags.SeeAlso, null)
422 
423         val valueElement = tag.referenceElement()
424         val externalLink = resolveExternalLink(valueElement)
425         val text = ContentText(linkElement.text)
426 
427         val linkSignature by lazy { resolveInternalLink(valueElement) }
428         val node = when {
429             externalLink != null -> {
430                 val linkNode = ContentExternalLink(externalLink)
431                 linkNode.append(text)
432                 linkNode
433             }
434             linkSignature != null -> {
435                 @Suppress("USELESS_CAST")
436                 val signature: String = linkSignature as String
437                 val linkNode =
438                     ContentNodeLazyLink(
439                         (tag.valueElement ?: linkElement).text
440                     ) { refGraph.lookupOrWarn(signature, logger) }
441                 linkNode.append(text)
442                 linkNode
443             }
444             else -> text
445         }
446         seeSection.append(node)
447     }
448 
449     private fun convertInlineDocTag(tag: PsiInlineDocTag, element: PsiNamedElement) = when (tag.name) {
450         "link", "linkplain" -> {
451             val valueElement = tag.referenceElement()
452             val externalLink = resolveExternalLink(valueElement)
453             val linkSignature by lazy { resolveInternalLink(valueElement) }
454             if (externalLink != null || linkSignature != null) {
455 
456                 // sometimes `dataElements` contains multiple `PsiDocToken` elements and some have whitespace in them
457                 // this is best effort to find the first non-empty one before falling back to using the symbol name.
458                 val labelText = tag.dataElements.firstOrNull {
459                     it is PsiDocToken && it.text?.trim()?.isNotEmpty() ?: false
460                 }?.text ?: valueElement!!.text
461 
462                 val linkTarget = if (externalLink != null) "href=\"$externalLink\"" else "docref=\"$linkSignature\""
463                 val link = "<a $linkTarget>$labelText</a>"
464                 if (tag.name == "link") "<code>$link</code>" else link
465             } else if (valueElement != null) {
466                 valueElement.text
467             } else {
468                 ""
469             }
470         }
471         "code", "literal" -> {
472             val text = StringBuilder()
473             tag.dataElements.forEach { text.append(it.text) }
474             val escaped = text.toString().trimStart().htmlEscape()
475             if (tag.name == "code") "<code>$escaped</code>" else escaped
476         }
477         "inheritDoc" -> {
478             val result = (element as? PsiMethod)?.let {
479                 // @{inheritDoc} is only allowed on functions
480                 val parent = tag.parent
481                 when (parent) {
482                     is PsiDocComment -> element.findSuperDocCommentOrWarn()
483                     is PsiDocTag -> element.findSuperDocTagOrWarn(parent)
484                     else -> null
485                 }
486             }
487             result ?: tag.text
488         }
489         "docRoot" -> {
490             // TODO: fix that
491             "https://developer.android.com/"
492         }
493         "sample" -> {
494             tag.text?.let { tagText ->
495                 val (absolutePath, delimiter) = getSampleAnnotationInformation(tagText)
496                 val code = retrieveCodeInFile(absolutePath, delimiter)
497                 return if (code != null && code.isNotEmpty()) {
498                     "<pre is-upgraded>$code</pre>"
499                 } else {
500                     ""
501                 }
502             }
503         }
504 
505         // Loads MathJax script from local source, which then updates MathJax HTML code
506         "usesMathJax" -> {
507             "<script src=\"/_static/js/managed/mathjax/MathJax.js?config=TeX-AMS_SVG\"></script>"
508         }
509 
510         else -> tag.text
511     }
512 
513     private fun PsiDocTag.referenceElement(): PsiElement? =
514         linkElement()?.let {
515             if (it.node.elementType == JavaDocElementType.DOC_REFERENCE_HOLDER) {
516                 PsiTreeUtil.findChildOfType(it, PsiJavaCodeReferenceElement::class.java)
517             } else {
518                 it
519             }
520         }
521 
522     private fun PsiDocTag.linkElement(): PsiElement? =
523         valueElement ?: dataElements.firstOrNull { it !is PsiWhiteSpace }
524 
525     private fun resolveExternalLink(valueElement: PsiElement?): String? {
526         val target = valueElement?.reference?.resolve()
527         if (target != null) {
528             return externalDocumentationLinkResolver.buildExternalDocumentationLink(target)
529         }
530         return null
531     }
532 
533     private fun resolveInternalLink(valueElement: PsiElement?): String? {
534         val target = valueElement?.reference?.resolve()
535         if (target != null) {
536             return signatureProvider.signature(target)
537         }
538         return null
539     }
540 
541     fun PsiDocTag.getSubjectName(): String? {
542         if (name == "param" || name == "throws" || name == "exception") {
543             return valueElement?.text
544         }
545         return null
546     }
547 
548     private fun PsiMethod.findSuperDocCommentOrWarn(): String {
549         val method = findFirstSuperMethodWithDocumentation(this)
550         if (method != null) {
551             val descriptionElements = method.docComment?.descriptionElements?.dropWhile {
552                 it.text.trim().isEmpty()
553             } ?: return ""
554 
555             return expandAllForElements(descriptionElements, method)
556         }
557         logger.warn("No docs found on supertype with {@inheritDoc} method ${this.name} in ${this.containingFile.name}:${this.lineNumber()}")
558         return ""
559     }
560 
561 
562     private fun PsiMethod.findSuperDocTagOrWarn(elementToExpand: PsiDocTag): String {
563         val result = findFirstSuperMethodWithDocumentationforTag(elementToExpand, this)
564 
565         if (result != null) {
566             val (method, tag) = result
567 
568             val contentElements = tag.contentElements().dropWhile { it.text.trim().isEmpty() }
569 
570             val expandedString = expandAllForElements(contentElements, method)
571 
572             return expandedString
573         }
574         logger.warn("No docs found on supertype for @${elementToExpand.name} ${elementToExpand.getSubjectName()} with {@inheritDoc} method ${this.name} in ${this.containingFile.name}:${this.lineNumber()}")
575         return ""
576     }
577 
578     private fun findFirstSuperMethodWithDocumentation(current: PsiMethod): PsiMethod? {
579         val superMethods = current.findSuperMethods()
580         for (method in superMethods) {
581             val docs = method.docComment?.descriptionElements?.dropWhile { it.text.trim().isEmpty() }
582             if (docs?.isNotEmpty() == true) {
583                 return method
584             }
585         }
586         for (method in superMethods) {
587             val result = findFirstSuperMethodWithDocumentation(method)
588             if (result != null) {
589                 return result
590             }
591         }
592 
593         return null
594     }
595 
596     private fun findFirstSuperMethodWithDocumentationforTag(
597         elementToExpand: PsiDocTag,
598         current: PsiMethod
599     ): Pair<PsiMethod, PsiDocTag>? {
600         val superMethods = current.findSuperMethods()
601         val mappedFilteredTags = superMethods.map {
602             it to it.docComment?.tags?.filter { it.name == elementToExpand.name }
603         }
604 
605         for ((method, tags) in mappedFilteredTags) {
606             tags ?: continue
607             for (tag in tags) {
608                 val (tagSubject, elementSubject) = when (tag.name) {
609                     "throws" -> {
610                         // match class names only for throws, ignore possibly fully qualified path
611                         // TODO: Always match exactly here
612                         tag.getSubjectName()?.split(".")?.last() to elementToExpand.getSubjectName()?.split(".")?.last()
613                     }
614                     else -> {
615                         tag.getSubjectName() to elementToExpand.getSubjectName()
616                     }
617                 }
618 
619                 if (tagSubject == elementSubject) {
620                     return method to tag
621                 }
622             }
623         }
624 
625         for (method in superMethods) {
626             val result = findFirstSuperMethodWithDocumentationforTag(elementToExpand, method)
627             if (result != null) {
628                 return result
629             }
630         }
631         return null
632     }
633 
634     /**
635      * Returns information inside @sample
636      *
637      * Component1 is the absolute path to the file
638      * Component2 is the delimiter if exists in the file
639      */
640     private fun getSampleAnnotationInformation(tagText: String): Pair<String, String> {
641         val pathContent = tagText
642             .trim { it == '{' || it == '}' }
643             .removePrefix("@sample ")
644 
645         val formattedPath = pathContent.substringBefore(" ").trim()
646         val potentialDelimiter = pathContent.substringAfterLast(" ").trim()
647 
648         val delimiter = if (potentialDelimiter == formattedPath) "" else potentialDelimiter
649         val path = "samples/$formattedPath"
650 
651         return Pair(path, delimiter)
652     }
653 
654     /**
655      * Retrieves the code inside a file.
656      *
657      * If betweenTag is not empty, it retrieves the code between
658      * BEGIN_INCLUDE($betweenTag) and END_INCLUDE($betweenTag) comments.
659      *
660      * Also, the method will trim every line with the number of spaces in the first line
661      */
662     private fun retrieveCodeInFile(path: String, betweenTag: String = "") = StringBuilder().apply {
663             try {
664                 if (betweenTag.isEmpty()) {
665                     appendContent(path)
666                 } else {
667                     appendContentBetweenIncludes(path, betweenTag)
668                 }
669             } catch (e: java.lang.Exception) {
670                 logger.error("No file found when processing Java @sample. Path to sample: $path\n")
671             }
672         }
673 
674     private fun StringBuilder.appendContent(path: String) {
675         val spaces = InitialSpaceIndent()
676         File(path).forEachLine {
677             appendWithoutInitialIndent(it, spaces)
678         }
679     }
680 
681     private fun StringBuilder.appendContentBetweenIncludes(path: String, includeTag: String) {
682         var shouldAppend = false
683         val beginning = "BEGIN_INCLUDE($includeTag)"
684         val end = "END_INCLUDE($includeTag)"
685         val spaces = InitialSpaceIndent()
686         File(path).forEachLine {
687             if (shouldAppend) {
688                 if (it.contains(end)) {
689                     shouldAppend = false
690                 } else {
691                     appendWithoutInitialIndent(it, spaces)
692                 }
693             } else {
694                 if (it.contains(beginning)) shouldAppend = true
695             }
696         }
697     }
698 
699     private fun StringBuilder.appendWithoutInitialIndent(it: String, spaces: InitialSpaceIndent) {
700         if (spaces.value == -1) {
701             spaces.value = (it.length - it.trimStart().length).coerceAtLeast(0)
702             appendln(it)
703         } else {
704             appendln(if (it.isBlank()) it else it.substring(spaces.value, it.length))
705         }
706     }
707 
708     private data class InitialSpaceIndent(var value: Int = -1)
709 }
710