Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,18 @@ class IconNameFormatterTest {
assertThat(iconName).isEqualTo(it.expected)
}
}

@Test
fun `check case-insensitive collision detection`() {
// These files produce different icon names that collide on case-insensitive file systems
val testCases = listOf(
IconTest(fileName = "test-icon.svg", expected = "TestIcon"),
IconTest(fileName = "testicon.svg", expected = "Testicon"),
)

testCases.forEach {
val iconName = IconNameFormatter.format(it.fileName)
assertThat(iconName).isEqualTo(it.expected)
}
}
}
2 changes: 2 additions & 0 deletions tools/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Added

- Add `--use-path-data-string` option to generate addPath with pathData strings instead of path builder calls
- Add validation for exact duplicate icon names (e.g., `test-icon.svg` and `test_icon.svg` both produce `TestIcon.kt`)
- Add validation for case-insensitive duplicate icon names to prevent file overwrites on macOS/Windows

### Removed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,9 @@ private fun svgXml2ImageVector(

outputInfo("Start processing...")

val fullQualifiedNames = iconPaths
.map { IconNameFormatter.format(name = it.name) }
.filter { reservedComposeQualifiers.contains(it) }
val iconNames = iconPaths.map { IconNameFormatter.format(name = it.name) }

val fullQualifiedNames = iconNames.filter { reservedComposeQualifiers.contains(it) }

if (fullQualifiedNames.isNotEmpty()) {
outputInfo(
Expand All @@ -218,6 +218,40 @@ private fun svgXml2ImageVector(
)
}

// Check for exact duplicates
val exactDuplicates = iconNames
.groupBy { it }
.filter { it.value.size > 1 }
.keys
.toList()
.sorted()

if (exactDuplicates.isNotEmpty()) {
outputError(
"Found duplicate icon names: ${exactDuplicates.joinToString(", ")}. " +
"Each icon must have a unique name. " +
"Please rename the source files to avoid duplicates.",
)
}

// Check for case-insensitive duplicates that would cause file overwrites on case-insensitive file systems
val caseInsensitiveDuplicates = iconNames
.groupBy { it.lowercase() }
.filter { it.value.size > 1 && it.value.distinct().size > 1 }
.values
.flatten()
.distinct()
.sorted()

if (caseInsensitiveDuplicates.isNotEmpty()) {
outputError(
"Found icon names that would collide on case-insensitive file systems (macOS/Windows): " +
"${caseInsensitiveDuplicates.joinToString(", ")}. " +
"These icons would overwrite each other during generation. " +
"Please rename the source files to avoid case-insensitive duplicates.",
)
}

val config = ImageVectorGeneratorConfig(
packageName = packageName,
iconPackPackage = packageName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,82 @@ class SvgXmlToImageVectorCommandTest {
verify { outputInfo("process = $path") }
}
}

@Test
fun `should throw error for case-insensitive duplicate icon names`() {
val mockMessage = "Found icon names that would collide on case-insensitive file systems (macOS/Windows): " +
"TestIcon, Testicon. " +
"These icons would overwrite each other during generation. " +
"Please rename the source files to avoid case-insensitive duplicates."

mockkStatic(::outputError)
every { outputError(mockMessage) } answers { error(mockMessage) }

val tempDir = createTempDirectory()

// Create two SVG files that produce case-insensitive duplicates:
// test-icon.svg -> TestIcon.kt
// testicon.svg -> Testicon.kt
// On case-insensitive file systems, these would collide
val svgContent = """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
</svg>
""".trimIndent()

tempDir.resolve("test-icon.svg").toFile().writeText(svgContent)
tempDir.resolve("testicon.svg").toFile().writeText(svgContent)

val exception = assertFailsWith<IllegalStateException> {
SvgXmlToImageVectorCommand().test(
"--input-path",
tempDir.absolutePathString(),
"--output-path",
createTempDirectory().absolutePathString(),
"--package-name",
"com.example",
)
}

assertThat(exception.message).isEqualTo(mockMessage)
verify { outputError(mockMessage) }
}

@Test
fun `should throw error for exact duplicate icon names`() {
val mockMessage = "Found duplicate icon names: TestIcon. " +
"Each icon must have a unique name. " +
"Please rename the source files to avoid duplicates."

mockkStatic(::outputError)
every { outputError(mockMessage) } answers { error(mockMessage) }

val tempDir = createTempDirectory()

val svgContent = """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
</svg>
""".trimIndent()

// Create two SVG files that produce the exact same icon name:
// test-icon.svg -> TestIcon.kt
// test_icon.svg -> TestIcon.kt (same result!)
tempDir.resolve("test-icon.svg").toFile().writeText(svgContent)
tempDir.resolve("test_icon.svg").toFile().writeText(svgContent)

val exception = assertFailsWith<IllegalStateException> {
SvgXmlToImageVectorCommand().test(
"--input-path",
tempDir.absolutePathString(),
"--output-path",
createTempDirectory().absolutePathString(),
"--package-name",
"com.example",
)
}

assertThat(exception.message).isEqualTo(mockMessage)
verify { outputError(mockMessage) }
}
}
4 changes: 4 additions & 0 deletions tools/gradle-plugin/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- Add `usePathDataString` configuration option in `imageVector` block to generate addPath with pathData strings instead
of path builder calls
- Add validation for exact duplicate icon names (e.g., `test-icon.svg` and `test_icon.svg` both produce `TestIcon.kt`)
- Add validation for case-insensitive duplicate icon names to prevent file overwrites on macOS/Windows
- Add nested pack aware validation that correctly handles `useFlatPackage` mode - when enabled, duplicates are detected
across all nested packs since they write to the same output folder

### Removed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
outputDirectory.mkdirs()

// Detect icons with names conflicting with reserved Compose qualifiers
val fullQualifiedNames = iconFiles.files
.map { IconNameFormatter.format(name = it.name) }
.filter { reservedComposeQualifiers.contains(it) }
val iconNames = iconFiles.files.map { IconNameFormatter.format(name = it.name) }

val fullQualifiedNames = iconNames.filter { reservedComposeQualifiers.contains(it) }

if (fullQualifiedNames.isNotEmpty()) {
logger.lifecycle(
Expand All @@ -116,6 +116,9 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {
)
}

// Check for duplicates with nested pack awareness
validateDuplicates(iconFiles.files.toList(), iconNames)

if (iconPack.isPresent && iconPack.get().targetSourceSet.get() == sourceSet.get()) {
generateIconPack(outputDirectory = outputDirectory)
}
Expand Down Expand Up @@ -391,4 +394,119 @@ internal abstract class GenerateImageVectorsTask : DefaultTask() {

return null
}

private fun validateDuplicates(files: List<File>, iconNames: List<String>) {
if (iconPack.isPresent) {
val pack = iconPack.get()
val nestedPacks = pack.nestedPacks.get()
val useFlatPackage = pack.useFlatPackage.get()

if (nestedPacks.isNotEmpty()) {
// Build map: file -> nested pack name
val sourceFolderToNestedPack = nestedPacks.associateBy { it.sourceFolder.get() }

// When useFlatPackage is false, only validate files that are in configured nested pack folders
// (matching the behavior of generateIconsForNestedPacks)
val filesToValidate = if (useFlatPackage) {
files
} else {
files.filter { file -> sourceFolderToNestedPack.containsKey(file.parentFile.name) }
}

val fileToNestedPack = filesToValidate.associateWith { file ->
sourceFolderToNestedPack[file.parentFile.name]?.name?.get()
}

// Group by nested pack (or single group if useFlatPackage)
val iconsByPack = filesToValidate.groupBy { file ->
if (useFlatPackage) {
pack.name.get() // All in same pack when flat
} else {
val nestedPackName = fileToNestedPack[file]
if (nestedPackName != null) "${pack.name.get()}.$nestedPackName" else pack.name.get()
}
}

iconsByPack.forEach { (packIdentifier, filesInPack) ->
val names = filesInPack.map { IconNameFormatter.format(name = it.name) }

// Check exact duplicates within this pack/group
val exactDuplicates = names
.groupBy { it }
.filter { it.value.size > 1 }
.keys
.toList()
.sorted()

if (exactDuplicates.isNotEmpty()) {
throw GradleException(
"Found duplicate icon names in \"$packIdentifier\": ${exactDuplicates.joinToString(", ")}. " +
"Each icon must have a unique name. " +
"Please rename the source files to avoid duplicates.",
)
}

// Check case-insensitive duplicates within this pack/group
val caseInsensitiveDuplicates = names
.groupBy { it.lowercase() }
.filter { it.value.size > 1 && it.value.distinct().size > 1 }
.values
.flatten()
.distinct()
.sorted()

if (caseInsensitiveDuplicates.isNotEmpty()) {
throw GradleException(
"Found icon names that would collide on case-insensitive file systems (macOS/Windows) in \"$packIdentifier\": " +
"${caseInsensitiveDuplicates.joinToString(", ")}. " +
"These icons would overwrite each other during generation. " +
"Please rename the source files to avoid case-insensitive duplicates.",
)
}
}
} else {
// Single pack - check all files together
checkDuplicatesInIconNames(iconNames, "icon pack \"${pack.name.get()}\"")
}
} else {
// No icon pack - check all files together
checkDuplicatesInIconNames(iconNames, "package \"${packageName.get()}\"")
}
}

private fun checkDuplicatesInIconNames(names: List<String>, context: String) {
// Check exact duplicates
val exactDuplicates = names
.groupBy { it }
.filter { it.value.size > 1 }
.keys
.toList()
.sorted()

if (exactDuplicates.isNotEmpty()) {
throw GradleException(
"Found duplicate icon names in $context: ${exactDuplicates.joinToString(", ")}. " +
"Each icon must have a unique name. " +
"Please rename the source files to avoid duplicates.",
)
}

// Check case-insensitive duplicates
val caseInsensitiveDuplicates = names
.groupBy { it.lowercase() }
.filter { it.value.size > 1 && it.value.distinct().size > 1 }
.values
.flatten()
.distinct()
.sorted()

if (caseInsensitiveDuplicates.isNotEmpty()) {
throw GradleException(
"Found icon names that would collide on case-insensitive file systems (macOS/Windows) in $context: " +
"${caseInsensitiveDuplicates.joinToString(", ")}. " +
"These icons would overwrite each other during generation. " +
"Please rename the source files to avoid case-insensitive duplicates.",
)
}
}
}
Loading