diff --git a/projects/koin-annotations/src/commonMain/kotlin/org/koin/core/annotation/CoreAnnotations.kt b/projects/koin-annotations/src/commonMain/kotlin/org/koin/core/annotation/CoreAnnotations.kt index 004e18c..06b052b 100644 --- a/projects/koin-annotations/src/commonMain/kotlin/org/koin/core/annotation/CoreAnnotations.kt +++ b/projects/koin-annotations/src/commonMain/kotlin/org/koin/core/annotation/CoreAnnotations.kt @@ -180,7 +180,26 @@ annotation class Module(val includes: Array> = [], val createdAtStart: * Gather definitions declared with Koin definition annotation * Will scan in current package or with the explicit package name * - * @param value: package to scan + * The [value] parameter supports both exact package names and glob patterns: + * + * 1. Exact package: `"com.example.service"` + * - Scans only the `com.example.service` package. + * + * 2. Single-level wildcard (`*`): `"com.example.*.service"` + * - Matches one level of package hierarchy. + * - E.g., `com.example.user.service`, `com.example.order.service`. + * - Does NOT match `com.example.service` or `com.example.user.impl.service`. + * + * 3. Multi-level wildcard (`**`): `"com.example.**"` + * - Matches any number of package levels. + * - E.g., `com.example`, `com.example.service`, `com.example.service.user`. + * + * Wildcards can be combined and used at any level: + * - `"com.**.service.*data"`: All packages that ends with "data" in any `service` subpackage. + * - `"com.*.service.**"`: All classes in `com.X.service` and its subpackages. + * + * @param value The package to scan. Can be an exact package name or a glob pattern. + * Defaults to the package of the annotated element if empty. */ @Target(AnnotationTarget.CLASS, AnnotationTarget.FIELD) annotation class ComponentScan(val value: String = "") \ No newline at end of file diff --git a/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/metadata/KoinMetaData.kt b/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/metadata/KoinMetaData.kt index 7b3ffe4..bc47126 100644 --- a/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/metadata/KoinMetaData.kt +++ b/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/metadata/KoinMetaData.kt @@ -18,6 +18,7 @@ package org.koin.compiler.metadata import com.google.devtools.ksp.symbol.KSDeclaration import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.Visibility +import org.koin.compiler.util.matchesGlob import java.util.* sealed class KoinMetaData { @@ -44,7 +45,7 @@ sealed class KoinMetaData { fun acceptDefinition(defPackageName: String): Boolean { return when { componentScan == null -> false - componentScan.packageName.isNotEmpty() -> defPackageName.contains( + componentScan.packageName.isNotEmpty() -> defPackageName.matchesGlob( componentScan.packageName, ignoreCase = true ) @@ -86,7 +87,7 @@ sealed class KoinMetaData { fun isNotScoped(): Boolean = !isScoped() fun isType(keyword: DefinitionAnnotation): Boolean = this.keyword == keyword - val packageNamePrefix : String = if (packageName.isEmpty()) "" else "${packageName}." + val packageNamePrefix: String = if (packageName.isEmpty()) "" else "${packageName}." override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/scanner/KoinMetaDataScanner.kt b/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/scanner/KoinMetaDataScanner.kt index 4a79642..8f5dad6 100644 --- a/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/scanner/KoinMetaDataScanner.kt +++ b/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/scanner/KoinMetaDataScanner.kt @@ -23,6 +23,7 @@ import com.google.devtools.ksp.symbol.KSFunctionDeclaration import com.google.devtools.ksp.validate import org.koin.compiler.metadata.DEFINITION_ANNOTATION_LIST_TYPES import org.koin.compiler.metadata.KoinMetaData +import org.koin.compiler.util.anyMatch import org.koin.core.annotation.Module class KoinMetaDataScanner( @@ -81,7 +82,7 @@ class KoinMetaDataScanner( module.componentScan?.let { scan -> when (scan.packageName) { "" -> emptyScanList.add(module) - else -> if (moduleList.contains(scan.packageName)) { + else -> if (moduleList.anyMatch(scan.packageName)) { val existing = moduleList[scan.packageName]!! error("@ComponentScan with '${scan.packageName}' from module ${module.name} is already declared in ${existing.name}. Please fix @ComponentScan value ") } else { diff --git a/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/util/GlobToRegex.kt b/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/util/GlobToRegex.kt new file mode 100644 index 0000000..bbe9f13 --- /dev/null +++ b/projects/koin-ksp-compiler/src/jvmMain/kotlin/org/koin/compiler/util/GlobToRegex.kt @@ -0,0 +1,89 @@ +package org.koin.compiler.util + +/** + * A utility object for converting glob patterns to regular expressions, primarily used for matching package names. + * + * Glob patterns are a simple way to match file paths and, in this context, package names. + * They use wildcards to represent variable parts of the name: + * + * - `*`: Matches any sequence of characters within a single package level. + * Example: `com.example.*` matches `com.example.foo` but not `com.example.foo.bar`. + * + * - `**`: Matches any sequence of characters across multiple package levels. + * Example: `com.example.**` matches both `com.example.foo` and `com.example.foo.bar`. + * + * @author OffRange + * @see [convert] + * @see [String.toGlobRegex] + * @see [String.matchesGlob] + */ +object GlobToRegex { + private const val DOT = '.' + private const val ESCAPED_DOT = "\\$DOT" + private const val NOT_DOT = "[^$DOT]" + + private const val MULTI_LEVEL_WILDCARD = "**" + private const val SINGLE_LEVEL_WILDCARD = "*" + + private const val GENERAL_SINGLE_LEVEL_PATTERN = "$NOT_DOT*" + private const val GENERAL_MULTI_LEVEL_PATTERN = "($GENERAL_SINGLE_LEVEL_PATTERN$ESCAPED_DOT)*$NOT_DOT+" + + /** + * Converts a glob pattern to a regular expression. + * + * Supports two types of wildcards: + * - `*`: Matches any characters within a single package level. + * - `**`: Matches any characters across multiple package levels. + * + * @param globPattern the glob pattern to convert, e.g., "com.example.**.service.*" + * @param ignoreCase if true, the resulting regex will be case-insensitive. Default is false, + * as package names in most JVM languages are case-sensitive. + * @return a [Regex] object that matches strings according to the given glob pattern. + * + * @throws IllegalArgumentException if the glob pattern is invalid or cannot be converted. + */ + fun convert(globPattern: String, ignoreCase: Boolean = false): Regex { + val parts = globPattern.split(DOT) + val regexParts = parts.map { part -> + when (part) { + MULTI_LEVEL_WILDCARD -> GENERAL_MULTI_LEVEL_PATTERN + SINGLE_LEVEL_WILDCARD -> GENERAL_SINGLE_LEVEL_PATTERN + else -> part.replace( + SINGLE_LEVEL_WILDCARD, + GENERAL_SINGLE_LEVEL_PATTERN + ) + } + } + return with("^${regexParts.joinToString(ESCAPED_DOT)}$") { + if (ignoreCase) toRegex(RegexOption.IGNORE_CASE) else toRegex() + } + } +} + +/** + * Converts this string, interpreted as a glob pattern, to a regular expression. + * + * @param ignoreCase if true, the resulting regex will be case-insensitive. + * Default is false, as package names are typically case-sensitive. + * @return a [Regex] object that matches strings according to this glob pattern. + */ +fun String.toGlobRegex(ignoreCase: Boolean = false): Regex = GlobToRegex.convert(this, ignoreCase) + +/** + * Checks if this map contains a key that matches the given glob pattern. + * + * @param keyGlob the glob pattern to match against keys. + * @return true if any key matches the glob pattern, false otherwise. + */ +fun Map.anyMatch(keyGlob: String): Boolean = keys.any { keyGlob.toGlobRegex().matches(it) } + +/** + * Checks if this string matches the given glob pattern. + * + * @param glob the glob pattern to match. + * @param ignoreCase if true, the match will be case-insensitive. + * Default is false, as package and class names are typically case-sensitive. + * @return true if this string matches the glob pattern, false otherwise. + */ +fun String.matchesGlob(glob: String, ignoreCase: Boolean = false): Boolean = + glob.toGlobRegex(ignoreCase = ignoreCase).matches(this) \ No newline at end of file