[duplicates] enable duplicates analysis in PyCharm/WebStorm/PhpStorm/RubyMine
[idea/community.git] / platform / build-scripts / icons / src / org / jetbrains / intellij / build / images / IconsClassGenerator.kt
1 // Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
2 package org.jetbrains.intellij.build.images
3
4 import com.intellij.openapi.util.io.FileUtilRt
5 import com.intellij.openapi.util.text.StringUtil
6 import com.intellij.util.LineSeparator
7 import com.intellij.util.containers.ContainerUtil
8 import com.intellij.util.diff.Diff
9 import org.jetbrains.jps.model.JpsSimpleElement
10 import org.jetbrains.jps.model.java.JavaSourceRootProperties
11 import org.jetbrains.jps.model.java.JavaSourceRootType
12 import org.jetbrains.jps.model.module.JpsModule
13 import org.jetbrains.jps.util.JpsPathUtil
14 import java.io.File
15 import java.nio.file.*
16 import java.util.*
17 import java.util.concurrent.atomic.AtomicInteger
18
19 data class ModifiedClass(val module: JpsModule, val file: Path, val result: CharSequence)
20
21 internal data class IconsClassInfo(val customLoad: Boolean,
22                                    val packageName: String,
23                                    val className: String,
24                                    val outFile: Path)
25
26 class IconsClassGenerator(private val projectHome: File, val util: JpsModule, private val writeChangesToDisk: Boolean = true) {
27   private val processedClasses = AtomicInteger()
28   private val processedIcons = AtomicInteger()
29   private val processedPhantom = AtomicInteger()
30   private val modifiedClasses = ContainerUtil.createConcurrentList<ModifiedClass>()
31   private val obsoleteClasses = ContainerUtil.createConcurrentList<Path>()
32
33   internal fun getIconsClassInfo(module: JpsModule) : IconsClassInfo? {
34     val customLoad: Boolean
35     val packageName: String
36     val className: String
37     val outFile: Path
38     when {
39       "intellij.platform.icons" == module.name -> {
40         customLoad = false
41         packageName = "com.intellij.icons"
42         className = "AllIcons"
43
44         val dir = util.getSourceRoots(JavaSourceRootType.SOURCE).first().file.absolutePath + "/com/intellij/icons"
45         outFile = Paths.get(dir, "AllIcons.java")
46       }
47       "intellij.android.artwork" == module.name -> {
48         // backward compatibility - AndroidIcons class should be not modified
49         packageName = "icons"
50         customLoad = true
51         className = "AndroidArtworkIcons"
52
53         val dir = module.getSourceRoots(JavaSourceRootType.SOURCE).first().file.absolutePath
54         outFile = Paths.get(dir, "icons", "AndroidArtworkIcons.java")
55       }
56       else -> {
57         customLoad = true
58         packageName = "icons"
59
60         val firstRoot = module.getSourceRoots(JavaSourceRootType.SOURCE).firstOrNull() ?: return null
61
62         val firstRootDir = firstRoot.file.toPath().resolve("icons")
63         var oldClassName: String?
64         // this is added to remove unneeded empty directories created by previous version of this script
65         if (Files.isDirectory(firstRootDir)) {
66           try {
67             Files.delete(firstRootDir)
68             println("deleting empty directory $firstRootDir")
69           }
70           catch (ignore: DirectoryNotEmptyException) {
71           }
72
73           oldClassName = findIconClass(firstRootDir)
74         }
75         else {
76           oldClassName = null
77         }
78
79         val generatedRoot = module.getSourceRoots(JavaSourceRootType.SOURCE).find { it.properties.isForGeneratedSources }
80         val targetRoot = (generatedRoot ?: firstRoot).file.toPath().resolve("icons")
81
82         if (generatedRoot != null && oldClassName != null) {
83           val oldFile = firstRootDir.resolve("$oldClassName.java")
84           println("deleting $oldFile from source root which isn't marked as 'generated'")
85           Files.delete(oldFile)
86         }
87         if (oldClassName == null) {
88           try {
89             oldClassName = findIconClass(targetRoot)
90           }
91           catch (ignored: NoSuchFileException) {
92           }
93         }
94
95         className = oldClassName ?: directoryName(module) + "Icons"
96         outFile = targetRoot.resolve("$className.java")
97       }
98     }
99     return IconsClassInfo(customLoad, packageName, className, outFile)
100   }
101
102   fun processModule(module: JpsModule) {
103     val iconsClassInfo = getIconsClassInfo(module) ?: return
104     val outFile = iconsClassInfo.outFile
105     val oldText = try {
106       Files.readAllBytes(outFile).toString(Charsets.UTF_8)
107     }
108     catch (ignored: NoSuchFileException) {
109       null
110     }
111     val newText = generate(module, iconsClassInfo, getCopyrightComment(oldText))
112
113     val oldLines = oldText?.lines() ?: emptyList()
114     val newLines = newText?.lines() ?: emptyList()
115
116     if (newLines.isNotEmpty()) {
117       processedClasses.incrementAndGet()
118
119       if (oldLines != newLines) {
120         if (writeChangesToDisk) {
121           val separator = getSeparators(oldText)
122           Files.createDirectories(outFile.parent)
123           Files.write(outFile, newLines.joinToString(separator = separator.separatorString).toByteArray())
124           println("Updated icons class: ${outFile.fileName}")
125         }
126         else {
127           val sb = StringBuilder()
128           var ch = Diff.buildChanges(oldLines.toTypedArray(), newLines.toTypedArray())
129           while (ch != null) {
130             val deleted = oldLines.subList(ch.line0, ch.line0 + ch.deleted)
131             val inserted = newLines.subList(ch.line1, ch.line1 + ch.inserted)
132
133             if (sb.isNotEmpty()) sb.append("=".repeat(20)).append("\n")
134             deleted.forEach { sb.append("-").append(it).append("\n") }
135             inserted.forEach { sb.append("+").append(it).append("\n") }
136
137             ch = ch.link
138           }
139
140           modifiedClasses.add(ModifiedClass(module, outFile, sb))
141         }
142       }
143     }
144     else {
145       if (Files.exists(outFile)) {
146         obsoleteClasses.add(outFile)
147       }
148     }
149   }
150
151   fun printStats() {
152     println()
153     println("Generated classes: ${processedClasses.get()}. Processed icons: ${processedIcons.get()}. Phantom icons: ${processedPhantom.get()}")
154     if (obsoleteClasses.isNotEmpty()) {
155       println("\nObsolete classes:")
156       println(obsoleteClasses.joinToString("\n"))
157       println("\nObsolete class it is class for icons that cannot be found anymore. Possible reasons:")
158       println("1. Icons not located under resources root.\n   Solution - move icons to resources root or fix existing root type (must be \"resources\")")
159       println("2. Icons were removed but not class.\n   Solution - remove class.")
160       println("3. Icons located under resources root named \"compatibilityResources\". \"compatibilityResources\" for icons that not used externally as icon class fields, " +
161               "but maybe referenced directly by path.\n   Solution - remove class or move icons to another resources root")
162     }
163   }
164
165   fun getModifiedClasses(): List<ModifiedClass> = modifiedClasses
166
167   private fun findIconClass(dir: Path): String? {
168     if (!dir.toFile().exists()) return null
169     Files.newDirectoryStream(dir).use { stream ->
170       for (it in stream) {
171         val name = it.fileName.toString()
172         if (name.endsWith("Icons.java")) {
173           return name.substring(0, name.length - ".java".length)
174         }
175       }
176     }
177     return null
178   }
179
180   private fun getCopyrightComment(text: String?): String {
181     if (text == null) return ""
182     val i = text.indexOf("package ")
183     if (i == -1) return ""
184     val comment = text.substring(0, i)
185     return if (comment.trim().endsWith("*/") || comment.trim().startsWith("//")) comment else ""
186   }
187
188   private fun getSeparators(text: String?): LineSeparator {
189     if (text == null) return LineSeparator.LF
190     return StringUtil.detectSeparators(text) ?: LineSeparator.LF
191   }
192
193   private fun generate(module: JpsModule, info: IconsClassInfo, copyrightComment: String): String? {
194     val imageCollector = ImageCollector(projectHome.toPath(), iconsOnly = true, className = info.className)
195
196     val images = imageCollector.collect(module, includePhantom = true)
197     if (images.isEmpty()) {
198       return null
199     }
200
201     imageCollector.printUsedIconRobots()
202
203     val answer = StringBuilder()
204     answer.append(copyrightComment)
205     append(answer, "package ${info.packageName};\n", 0)
206     append(answer, "import com.intellij.openapi.util.IconLoader;", 0)
207     append(answer, "", 0)
208     append(answer, "import javax.swing.*;", 0)
209     append(answer, "", 0)
210
211     // IconsGeneratedSourcesFilter depends on following comment, if you going to change the text
212     // please do corresponding changes in IconsGeneratedSourcesFilter as well
213     append(answer, "/**", 0)
214     append(answer, " * NOTE THIS FILE IS AUTO-GENERATED", 0)
215     append(answer, " * DO NOT EDIT IT BY HAND, run \"Generate icon classes\" configuration instead", 0)
216     append(answer, " */", 0)
217
218
219     answer.append("public")
220     // backward compatibility
221     if (info.className != "AllIcons") {
222       answer.append(" final")
223     }
224     answer.append(" class ").append(info.className).append(" {\n")
225     if (info.customLoad) {
226       append(answer, "private static Icon load(String path) {", 1)
227       append(answer, "return IconLoader.getIcon(path, ${info.className}.class);", 2)
228       append(answer, "}", 1)
229       append(answer, "", 0)
230
231       val customExternalLoad = images.any { it.deprecation?.replacementContextClazz != null }
232       if (customExternalLoad) {
233         append(answer, "private static Icon load(String path, Class<?> clazz) {", 1)
234         append(answer, "return IconLoader.getIcon(path, clazz);", 2)
235         append(answer, "}", 1)
236         append(answer, "", 0)
237       }
238     }
239
240     val inners = StringBuilder()
241     processIcons(images, inners, info.customLoad, 0)
242     if (inners.isEmpty()) return null
243
244     answer.append(inners)
245     append(answer, "}", 0)
246     return answer.toString()
247   }
248
249   private fun processIcons(images: List<ImagePaths>, answer: StringBuilder, customLoad: Boolean, depth: Int) {
250     val level = depth + 1
251
252     val (nodes, leafs) = images.partition { getImageId(it, depth).contains('/') }
253     val nodeMap = nodes.groupBy { getImageId(it, depth).substringBefore('/') }
254     val leafMap = ContainerUtil.newMapFromValues(leafs.iterator()) { getImageId(it, depth) }
255
256     fun getWeight(key: String): Int {
257       val image = leafMap[key]
258       if (image == null) {
259         return 0
260       }
261       return if (image.deprecated) 1 else 0
262     }
263
264     val sortedKeys = (nodeMap.keys + leafMap.keys)
265       .sortedWith(NAME_COMPARATOR)
266       .sortedWith(kotlin.Comparator(function = { o1, o2 ->
267         getWeight(o1) - getWeight(o2)
268       }))
269
270     for (key in sortedKeys) {
271       val group = nodeMap[key]
272       val image = leafMap[key]
273       if (group != null) {
274         val inners = StringBuilder()
275         processIcons(group, inners, customLoad, depth + 1)
276
277         if (inners.isNotEmpty()) {
278           append(answer, "", level)
279           append(answer, "public final static class " + className(key) + " {", level)
280           append(answer, inners.toString(), 0)
281           append(answer, "}", level)
282         }
283       }
284
285       if (image != null) {
286         appendImage(image, answer, level, customLoad)
287       }
288     }
289   }
290
291   private fun appendImage(image: ImagePaths,
292                           answer: StringBuilder,
293                           level: Int,
294                           customLoad: Boolean) {
295     val file = image.file ?: return
296     if (!image.phantom && !isIcon(file)) {
297       return
298     }
299
300     processedIcons.incrementAndGet()
301     if (image.phantom) {
302       processedPhantom.incrementAndGet()
303     }
304
305     if (image.used || image.deprecated) {
306       val deprecationComment = image.deprecation?.comment
307       append(answer, "", level)
308       if (deprecationComment != null) {
309         append(answer, "/** @deprecated $deprecationComment */", level)
310       }
311       append(answer, "@SuppressWarnings(\"unused\")", level)
312     }
313     if (image.deprecated) {
314       append(answer, "@Deprecated", level)
315     }
316
317     val sourceRoot = image.sourceRoot
318     var rootPrefix = "/"
319     if (sourceRoot.rootType == JavaSourceRootType.SOURCE) {
320       @Suppress("UNCHECKED_CAST")
321       val packagePrefix = (sourceRoot.properties as JpsSimpleElement<JavaSourceRootProperties>).data.packagePrefix
322       if (!packagePrefix.isEmpty()) {
323         rootPrefix += packagePrefix.replace('.', '/') + "/"
324       }
325     }
326
327     val iconName = iconName(file)
328     val deprecation = image.deprecation
329
330     if (deprecation?.replacementContextClazz != null) {
331       val method = if (customLoad) "load" else "IconLoader.getIcon"
332       append(answer,
333              "public static final Icon $iconName = $method(\"${deprecation.replacement}\", ${deprecation.replacementContextClazz}.class);",
334              level)
335       return
336     }
337     else if (deprecation?.replacementReference != null) {
338       append(answer, "public static final Icon $iconName = ${deprecation.replacementReference};", level)
339       return
340     }
341
342     val sourceRootFile = Paths.get(JpsPathUtil.urlToPath(sourceRoot.url))
343     val imageFile: Path
344     if (deprecation?.replacement == null) {
345       imageFile = file
346     }
347     else {
348       imageFile = sourceRootFile.resolve(deprecation.replacement.removePrefix("/").removePrefix(File.separator))
349       assert(isIcon(imageFile)) { "Overriding icon should be valid: $iconName - $imageFile" }
350     }
351
352     val size = if (imageFile.toFile().exists()) imageSize(imageFile) else null
353     val javaDoc = when {
354       size != null -> "/** ${size.width}x${size.height} */ "
355       !image.phantom -> error("Can't get icon size: $imageFile")
356       else -> ""
357     }
358     val method = if (customLoad) "load" else "IconLoader.getIcon"
359     val relativePath = rootPrefix + FileUtilRt.toSystemIndependentName(sourceRootFile.relativize(imageFile).toString())
360     append(answer, "${javaDoc}public static final Icon $iconName = $method(\"$relativePath\");", level)
361   }
362
363   private fun append(answer: StringBuilder, text: String, level: Int) {
364     if (text.isNotBlank()) {
365       for (i in 0 until level) {
366         answer.append("  ")
367       }
368     }
369     answer.append(text).append('\n')
370   }
371
372   private fun getImageId(image: ImagePaths, depth: Int): String {
373     val path = image.id.removePrefix("/").split("/")
374     if (path.size < depth) {
375       throw IllegalArgumentException("Can't get image ID - ${image.id}, $depth")
376     }
377     return path.drop(depth).joinToString("/")
378   }
379
380   private fun directoryName(module: JpsModule): String {
381     return directoryNameFromConfig(module) ?: className(module.name)
382   }
383
384   private fun directoryNameFromConfig(module: JpsModule): String? {
385     val rootUrl = getFirstContentRootUrl(module) ?: return null
386     val rootDir = File(JpsPathUtil.urlToPath(rootUrl))
387     if (!rootDir.isDirectory) return null
388
389     val file = File(rootDir, ROBOTS_FILE_NAME)
390     if (!file.exists()) return null
391
392     val prefix = "name:"
393     var moduleName: String? = null
394     file.forEachLine {
395       if (it.startsWith(prefix)) {
396         val name = it.substring(prefix.length).trim()
397         if (name.isNotEmpty()) moduleName = name
398       }
399     }
400     return moduleName
401   }
402
403   private fun getFirstContentRootUrl(module: JpsModule): String? {
404     return module.contentRootsList.urls.firstOrNull()
405   }
406
407   private fun className(name: String): String {
408     val answer = StringBuilder()
409     name.removePrefix("intellij.").split("-", "_", ".").forEach {
410       answer.append(capitalize(it))
411     }
412     return toJavaIdentifier(answer.toString())
413   }
414
415   private fun iconName(file: Path): String {
416     val name = capitalize(file.fileName.toString().substringBeforeLast('.'))
417     return toJavaIdentifier(name)
418   }
419
420   private fun toJavaIdentifier(id: String): String {
421     val sb = StringBuilder()
422     id.forEach {
423       if (Character.isJavaIdentifierPart(it)) {
424         sb.append(it)
425       }
426       else {
427         sb.append('_')
428       }
429     }
430
431     if (Character.isJavaIdentifierStart(sb.first())) {
432       return sb.toString()
433     }
434     else {
435       return "_" + sb.toString()
436     }
437   }
438
439   private fun capitalize(name: String): String {
440     if (name.length == 2) return name.toUpperCase()
441     return name.capitalize()
442   }
443
444   // legacy ordering
445   private val NAME_COMPARATOR: Comparator<String> = compareBy { it.toLowerCase() + "." }
446 }