Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: @ComponentScan glob support #131

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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 @@ -180,7 +180,26 @@ annotation class Module(val includes: Array<KClass<*>> = [], 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 = "")
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, *>.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)