1 /* 2 * Copyright (c) Tor Norbye. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 @file:Suppress("PropertyName", "PrivatePropertyName") 18 19 package com.facebook.ktfmt.kdoc 20 21 import com.google.common.truth.Truth.assertThat 22 import java.io.BufferedReader 23 import java.io.File 24 25 /** 26 * Verifies that two KDoc comment strings render to the same HTML documentation using Dokka. This is 27 * used by the test infrastructure to make sure that the transformations we're allowing are not 28 * changing the appearance of the documentation. 29 * 30 * Unfortunately, just diffing HTML strings isn't always enough, because dokka will preserve some 31 * text formatting which is immaterial to the HTML appearance. Therefore, if you've also installed 32 * Pandoc, it will use that to generate a text rendering of the HTML which is then used for diffing 33 * instead. (Even this isn't fullproof because pandoc also preserves some details that should not 34 * matter). Text rendering does drop a lot of markup (such as bold and italics) so it would be 35 * better to compare in some other format, such as PDF, but unfortunately, the PDF rendering doesn't 36 * appear to be stable; rendering the same document twice yields a binary diff. 37 * 38 * Dokka no longer provides a fat/shadow jar; instead you have to download a bunch of different 39 * dependencies. Therefore, for convenience this is set up to point to an AndroidX checkout, which 40 * has all the prebuilts. Point the below to AndroidX and the rest should work. 41 */ 42 class DokkaVerifier(private val tempFolder: File) { 43 // Configuration parameters 44 // Checkout of https://github.com/androidx/androidx 45 private val ANDROIDX_HOME: String? = null 46 47 // Optional install of pandoc, e.g. "/opt/homebrew/bin/pandoc" 48 private val PANDOC: String? = null 49 50 // JDK install 51 private val JAVA_HOME: String? = System.getenv("JAVA_HOME") ?: System.getProperty("java.home") 52 verifynull53 fun verify(before: String, after: String) { 54 JAVA_HOME ?: return 55 ANDROIDX_HOME ?: return 56 57 val androidx = File(ANDROIDX_HOME) 58 if (!androidx.isDirectory) { 59 return 60 } 61 62 val prebuilts = File(androidx, "prebuilts") 63 if (!prebuilts.isDirectory) { 64 println("AndroidX prebuilts not found; not verifying with Dokka") 65 } 66 val cli = find(prebuilts, "org.jetbrains.dokka", "dokka-cli") 67 val analysis = find(prebuilts, "org.jetbrains.dokka", "dokka-analysis") 68 val base = find(prebuilts, "org.jetbrains.dokka", "dokka-base") 69 val compiler = find(prebuilts, "org.jetbrains.dokka", "kotlin-analysis-compiler") 70 val intellij = find(prebuilts, "org.jetbrains.dokka", "kotlin-analysis-intellij") 71 val coroutines = find(prebuilts, "org.jetbrains.kotlinx", "kotlinx-coroutines-core") 72 val html = find(prebuilts, "org.jetbrains.kotlinx", "kotlinx-html-jvm") 73 val freemarker = find(prebuilts, "org.freemarker", "freemarker") 74 75 val src = File(tempFolder, "src") 76 val out = File(tempFolder, "dokka") 77 src.mkdirs() 78 out.mkdirs() 79 80 val beforeFile = File(src, "before.kt") 81 beforeFile.writeText("${before.split("\n").joinToString("\n") { it.trim() }}\nclass Before\n") 82 83 val afterFile = File(src, "after.kt") 84 afterFile.writeText("${after.split("\n").joinToString("\n") { it.trim() }}\nclass After\n") 85 86 val args = mutableListOf<String>() 87 args.add(File(JAVA_HOME, "bin/java").path) 88 args.add("-jar") 89 args.add(cli.path) 90 args.add("-pluginsClasspath") 91 val pathSeparator = 92 ";" // instead of File.pathSeparator as would have been reasonable (e.g. : on Unix) 93 val path = 94 listOf(analysis, base, compiler, intellij, coroutines, html, freemarker).joinToString( 95 pathSeparator) { 96 it.path 97 } 98 args.add(path) 99 args.add("-sourceSet") 100 args.add("-src $src") // (nested parameter within -sourceSet) 101 args.add("-outputDir") 102 args.add(out.path) 103 executeProcess(args) 104 105 fun getHtml(file: File): String { 106 val rendered = file.readText() 107 val begin = rendered.indexOf("<div class=\"copy-popup-wrapper popup-to-left\">") 108 val end = rendered.indexOf("<div class=\"tabbedcontent\">", begin) 109 return rendered.substring(begin, end).replace(Regex(" +"), " ").replace(">", ">\n") 110 } 111 112 fun getText(file: File): String? { 113 return if (PANDOC != null) { 114 val pandocFile = File(PANDOC) 115 if (!pandocFile.isFile) { 116 error("Cannot execute $pandocFile") 117 } 118 val outFile = File(out, "text.text") 119 executeProcess(listOf(PANDOC, file.path, "-o", outFile.path)) 120 val rendered = outFile.readText() 121 122 val begin = rendered.indexOf("[]{.copy-popup-icon}Content copied to clipboard") 123 val end = rendered.indexOf("::: tabbedcontent", begin) 124 rendered.substring(begin, end).replace(Regex(" +"), " ").replace(">", ">\n") 125 } else { 126 null 127 } 128 } 129 130 val indexBefore = File("$out/root/[root]/-before/index.html") 131 val beforeContents = getHtml(indexBefore) 132 val indexAfter = File("$out/root/[root]/-after/index.html") 133 val afterContents = getHtml(indexAfter) 134 if (beforeContents != afterContents) { 135 val beforeText = getText(indexBefore) 136 val afterText = getText(indexAfter) 137 if (beforeText != null && afterText != null) { 138 assertThat(beforeText).isEqualTo(afterText) 139 return 140 } 141 142 assertThat(beforeContents).isEqualTo(afterContents) 143 } 144 } 145 findnull146 private fun find(prebuilts: File, group: String, artifact: String): File { 147 val versionDir = File(prebuilts, "androidx/external/${group.replace('.','/')}/$artifact") 148 val versions = 149 versionDir.listFiles().filter { it.name.first().isDigit() }.sortedByDescending { it.name } 150 for (version in versions.map { it.name }) { 151 val jar = File(versionDir, "$version/$artifact-$version.jar") 152 if (jar.isFile) { 153 return jar 154 } 155 } 156 error("Could not find a valid jar file for $group:$artifact") 157 } 158 executeProcessnull159 private fun executeProcess(args: List<String>) { 160 var input: BufferedReader? = null 161 var error: BufferedReader? = null 162 try { 163 val process = Runtime.getRuntime().exec(args.toTypedArray()) 164 input = process.inputStream.bufferedReader() 165 error = process.errorStream.bufferedReader() 166 val exitVal = process.waitFor() 167 if (exitVal != 0) { 168 val sb = StringBuilder() 169 sb.append("Failed to execute process\n") 170 sb.append("Command args:\n") 171 for (arg in args) { 172 sb.append(" ").append(arg).append("\n") 173 } 174 sb.append("Standard output:\n") 175 var line: String? 176 while (input.readLine().also { line = it } != null) { 177 sb.append(line).append("\n") 178 } 179 sb.append("Error output:\n") 180 while (error.readLine().also { line = it } != null) { 181 sb.append(line).append("\n") 182 } 183 error(sb.toString()) 184 } 185 } catch (t: Throwable) { 186 val sb = StringBuilder() 187 for (arg in args) { 188 sb.append(" ").append(arg).append("\n") 189 } 190 t.printStackTrace() 191 error("Could not run process:\n$sb") 192 } finally { 193 input?.close() 194 error?.close() 195 } 196 } 197 } 198