Reuse code from build-common
KT-8487
This commit is contained in:
@@ -92,12 +92,11 @@ open class IncrementalCacheImpl<Target>(
|
|||||||
private val dependents = arrayListOf<IncrementalCacheImpl<Target>>()
|
private val dependents = arrayListOf<IncrementalCacheImpl<Target>>()
|
||||||
private val outputDir by lazy(LazyThreadSafetyMode.NONE) { requireNotNull(targetOutputDir) { "Target is expected to have output directory: $target" } }
|
private val outputDir by lazy(LazyThreadSafetyMode.NONE) { requireNotNull(targetOutputDir) { "Target is expected to have output directory: $target" } }
|
||||||
|
|
||||||
// TODO: review
|
val thisWithDependentCaches: Iterable<IncrementalCacheImpl<Target>> by lazy {
|
||||||
val dependentsWithThis: Sequence<IncrementalCacheImpl<Target>>
|
val result = arrayListOf(this)
|
||||||
get() = sequenceOf(this).plus(dependents.asSequence())
|
result.addAll(dependents)
|
||||||
|
result
|
||||||
internal val dependentCaches: Iterable<IncrementalCacheImpl<Target>>
|
}
|
||||||
get() = dependents
|
|
||||||
|
|
||||||
override fun registerInline(fromPath: String, jvmSignature: String, toPath: String) {
|
override fun registerInline(fromPath: String, jvmSignature: String, toPath: String) {
|
||||||
}
|
}
|
||||||
@@ -578,7 +577,7 @@ open class IncrementalCacheImpl<Target>(
|
|||||||
|
|
||||||
val removedFqNames = removedClasses.map { it.fqNameForClassNameWithoutDollars }.toSet()
|
val removedFqNames = removedClasses.map { it.fqNameForClassNameWithoutDollars }.toSet()
|
||||||
|
|
||||||
for (cache in dependentsWithThis) {
|
for (cache in thisWithDependentCaches) {
|
||||||
val parentsFqNames = hashSetOf<FqName>()
|
val parentsFqNames = hashSetOf<FqName>()
|
||||||
val childrenFqNames = hashSetOf<FqName>()
|
val childrenFqNames = hashSetOf<FqName>()
|
||||||
|
|
||||||
|
|||||||
@@ -174,36 +174,94 @@ fun<Target> OutputItemsCollectorImpl.generatedFiles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun<Target> CompilationResult.dirtyLookups(
|
data class DirtyData(
|
||||||
caches: Sequence<IncrementalCacheImpl<TargetId>>
|
val dirtyLookupSymbols: Iterable<LookupSymbol>,
|
||||||
): Iterable<LookupSymbol> =
|
val dirtyClassesFqNames: Iterable<FqName>
|
||||||
changes.asIterable().flatMap { change ->
|
)
|
||||||
when (change) {
|
|
||||||
is ChangeInfo.SignatureChanged -> {
|
fun <Target> CompilationResult.getDirtyData(
|
||||||
val fqNames = if (!change.areSubclassesAffected) listOf(change.fqName) else withSubtypes(change.fqName, caches)
|
caches: Iterable<IncrementalCacheImpl<Target>>,
|
||||||
fqNames.map {
|
log: (String)->Unit
|
||||||
val scope = it.parent().asString()
|
): DirtyData {
|
||||||
val name = it.shortName().identifier
|
val dirtyLookupSymbols = HashSet<LookupSymbol>()
|
||||||
LookupSymbol(name, scope)
|
val dirtyClassesFqNames = HashSet<FqName>()
|
||||||
}
|
|
||||||
}
|
for (change in changes) {
|
||||||
is ChangeInfo.MembersChanged -> {
|
log("Process $change")
|
||||||
val scopes = withSubtypes(change.fqName, caches).map { it.asString() }
|
|
||||||
change.names.flatMap { name -> scopes.map { scope -> LookupSymbol(name, scope) } }
|
if (change is ChangeInfo.SignatureChanged) {
|
||||||
}
|
val fqNames = if (!change.areSubclassesAffected) listOf(change.fqName) else withSubtypes(change.fqName, caches)
|
||||||
else -> listOf<LookupSymbol>()
|
|
||||||
|
for (classFqName in fqNames) {
|
||||||
|
assert(!classFqName.isRoot) { "$classFqName is root when processing $change" }
|
||||||
|
|
||||||
|
val scope = classFqName.parent().asString()
|
||||||
|
val name = classFqName.shortName().identifier
|
||||||
|
dirtyLookupSymbols.add(LookupSymbol(name, scope))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (change is ChangeInfo.MembersChanged) {
|
||||||
|
val fqNames = withSubtypes(change.fqName, caches)
|
||||||
|
// need to recompile subtypes because changed member might break override
|
||||||
|
dirtyClassesFqNames.addAll(fqNames)
|
||||||
|
|
||||||
|
for (name in change.names) {
|
||||||
|
for (fqName in fqNames) {
|
||||||
|
dirtyLookupSymbols.add(LookupSymbol(name, fqName.asString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DirtyData(dirtyLookupSymbols, dirtyClassesFqNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapLookupSymbolsToFiles(
|
||||||
|
lookupStorage: LookupStorage,
|
||||||
|
lookupSymbols: Iterable<LookupSymbol>,
|
||||||
|
log: (String)->Unit,
|
||||||
|
excludes: Set<File> = emptySet()
|
||||||
|
): Set<File> {
|
||||||
|
val dirtyFiles = HashSet<File>()
|
||||||
|
|
||||||
|
for (lookup in lookupSymbols) {
|
||||||
|
val affectedFiles = lookupStorage.get(lookup).map(::File).filter { it !in excludes }
|
||||||
|
log("${lookup.scope}#${lookup.name} caused recompilation of: $affectedFiles")
|
||||||
|
dirtyFiles.addAll(affectedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirtyFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <Target> mapClassesFqNamesToFiles(
|
||||||
|
caches: Iterable<IncrementalCacheImpl<Target>>,
|
||||||
|
classesFqNames: Iterable<FqName>,
|
||||||
|
log: (String)->Unit,
|
||||||
|
excludes: Set<File> = emptySet()
|
||||||
|
): Set<File> {
|
||||||
|
val dirtyFiles = HashSet<File>()
|
||||||
|
|
||||||
|
for (cache in caches) {
|
||||||
|
for (dirtyClassFqName in classesFqNames) {
|
||||||
|
val srcFile = cache.getSourceFileIfClass(dirtyClassFqName)
|
||||||
|
if (srcFile == null || srcFile in excludes) continue
|
||||||
|
|
||||||
|
log("Class $dirtyClassFqName caused recompilation of: $srcFile")
|
||||||
|
dirtyFiles.add(srcFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirtyFiles
|
||||||
|
}
|
||||||
|
|
||||||
private fun File.isJavaFile() = extension.equals(JavaFileType.INSTANCE.defaultExtension, ignoreCase = true)
|
private fun File.isJavaFile() = extension.equals(JavaFileType.INSTANCE.defaultExtension, ignoreCase = true)
|
||||||
|
|
||||||
private fun findSrcDirRoot(file: File, roots: Iterable<File>): File? =
|
private fun findSrcDirRoot(file: File, roots: Iterable<File>): File? =
|
||||||
roots.firstOrNull { FileUtil.isAncestor(it, file, false) }
|
roots.firstOrNull { FileUtil.isAncestor(it, file, false) }
|
||||||
|
|
||||||
private fun<TargetId> withSubtypes(
|
private fun <Target> withSubtypes(
|
||||||
typeFqName: FqName,
|
typeFqName: FqName,
|
||||||
caches: Sequence<IncrementalCacheImpl<TargetId>>
|
caches: Iterable<IncrementalCacheImpl<Target>>
|
||||||
): Set<FqName> {
|
): Set<FqName> {
|
||||||
val types = LinkedList(listOf(typeFqName))
|
val types = LinkedList(listOf(typeFqName))
|
||||||
val subtypes = hashSetOf<FqName>()
|
val subtypes = hashSetOf<FqName>()
|
||||||
@@ -211,9 +269,10 @@ private fun<TargetId> withSubtypes(
|
|||||||
while (types.isNotEmpty()) {
|
while (types.isNotEmpty()) {
|
||||||
val unprocessedType = types.pollFirst()
|
val unprocessedType = types.pollFirst()
|
||||||
|
|
||||||
caches.flatMap { it.getSubtypesOf(unprocessedType) }
|
caches.asSequence()
|
||||||
.filter { it !in subtypes }
|
.flatMap { it.getSubtypesOf(unprocessedType) }
|
||||||
.forEach { types.addLast(it) }
|
.filter { it !in subtypes }
|
||||||
|
.forEach { types.addLast(it) }
|
||||||
|
|
||||||
subtypes.add(unprocessedType)
|
subtypes.add(unprocessedType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import org.jetbrains.kotlin.jps.incremental.*
|
|||||||
import org.jetbrains.kotlin.load.kotlin.incremental.components.IncrementalCache
|
import org.jetbrains.kotlin.load.kotlin.incremental.components.IncrementalCache
|
||||||
import org.jetbrains.kotlin.load.kotlin.incremental.components.IncrementalCompilationComponents
|
import org.jetbrains.kotlin.load.kotlin.incremental.components.IncrementalCompilationComponents
|
||||||
import org.jetbrains.kotlin.modules.TargetId
|
import org.jetbrains.kotlin.modules.TargetId
|
||||||
import org.jetbrains.kotlin.name.FqName
|
|
||||||
import org.jetbrains.kotlin.progress.CompilationCanceledException
|
import org.jetbrains.kotlin.progress.CompilationCanceledException
|
||||||
import org.jetbrains.kotlin.progress.CompilationCanceledStatus
|
import org.jetbrains.kotlin.progress.CompilationCanceledStatus
|
||||||
import org.jetbrains.kotlin.utils.JsLibraryUtils
|
import org.jetbrains.kotlin.utils.JsLibraryUtils
|
||||||
@@ -773,88 +772,20 @@ private fun CompilationResult.doProcessChangesUsingLookups(
|
|||||||
compiledFiles: Set<File>,
|
compiledFiles: Set<File>,
|
||||||
dataManager: BuildDataManager,
|
dataManager: BuildDataManager,
|
||||||
fsOperations: FSOperationsHelper,
|
fsOperations: FSOperationsHelper,
|
||||||
caches: Collection<IncrementalCacheImpl<*>>
|
caches: Iterable<IncrementalCacheImpl<ModuleBuildTarget>>
|
||||||
) {
|
) {
|
||||||
val dirtyLookupSymbols = HashSet<LookupSymbol>()
|
|
||||||
val dirtyClassesFqNames = HashSet<FqName>()
|
|
||||||
val lookupStorage = dataManager.getStorage(KotlinDataContainerTarget, JpsLookupStorageProvider)
|
val lookupStorage = dataManager.getStorage(KotlinDataContainerTarget, JpsLookupStorageProvider)
|
||||||
val allCaches: Sequence<IncrementalCacheImpl<*>> = caches.asSequence().flatMap { it.dependentsWithThis }
|
val allCaches = caches.flatMap { it.thisWithDependentCaches }
|
||||||
|
val logAction = { logStr: String -> KotlinBuilder.LOG.debug(logStr) }
|
||||||
|
|
||||||
KotlinBuilder.LOG.debug("Start processing changes")
|
logAction("Start processing changes")
|
||||||
|
|
||||||
for (change in changes) {
|
|
||||||
KotlinBuilder.LOG.debug("Process $change")
|
|
||||||
|
|
||||||
if (change is ChangeInfo.SignatureChanged) {
|
|
||||||
val fqNames = if (!change.areSubclassesAffected) listOf(change.fqName) else withSubtypes(change.fqName, allCaches)
|
|
||||||
|
|
||||||
for (classFqName in fqNames) {
|
|
||||||
assert(!classFqName.isRoot) { "classFqName is root when processing $change" }
|
|
||||||
|
|
||||||
val scope = classFqName.parent().asString()
|
|
||||||
val name = classFqName.shortName().identifier
|
|
||||||
dirtyLookupSymbols.add(LookupSymbol(name, scope))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (change is ChangeInfo.MembersChanged) {
|
|
||||||
val fqNames = withSubtypes(change.fqName, allCaches)
|
|
||||||
// need to recompile subtypes because changed member might break override
|
|
||||||
dirtyClassesFqNames.addAll(fqNames)
|
|
||||||
|
|
||||||
change.names.forAllPairs(fqNames) { name, fqName ->
|
|
||||||
dirtyLookupSymbols.add(LookupSymbol(name, fqName.asString()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val dirtyFiles = HashSet<File>()
|
|
||||||
|
|
||||||
for (lookup in dirtyLookupSymbols) {
|
|
||||||
val affectedFiles = lookupStorage.get(lookup).map(::File)
|
|
||||||
|
|
||||||
KotlinBuilder.LOG.debug { "${lookup.scope}#${lookup.name} caused recompilation of: $affectedFiles" }
|
|
||||||
|
|
||||||
dirtyFiles.addAll(affectedFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (cache in allCaches) {
|
|
||||||
for (dirtyClassFqName in dirtyClassesFqNames) {
|
|
||||||
val srcFile = cache.getSourceFileIfClass(dirtyClassFqName) ?: continue
|
|
||||||
dirtyFiles.add(srcFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
val (dirtyLookupSymbols, dirtyClassFqNames) = getDirtyData(allCaches, logAction)
|
||||||
|
val dirtyFiles = mapLookupSymbolsToFiles(lookupStorage, dirtyLookupSymbols, logAction) +
|
||||||
|
mapClassesFqNamesToFiles(allCaches, dirtyClassFqNames, logAction)
|
||||||
fsOperations.markFiles(dirtyFiles.asIterable(), excludeFiles = compiledFiles)
|
fsOperations.markFiles(dirtyFiles.asIterable(), excludeFiles = compiledFiles)
|
||||||
KotlinBuilder.LOG.debug("End of processing changes")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
logAction("End of processing changes")
|
||||||
* Returns type with its subtypes transitively
|
|
||||||
*
|
|
||||||
* For example:
|
|
||||||
* open class A
|
|
||||||
* open class B : A()
|
|
||||||
* class C : B()
|
|
||||||
* withSubtypes(A) will return [A, B, C]
|
|
||||||
*/
|
|
||||||
private fun withSubtypes(
|
|
||||||
typeFqName: FqName,
|
|
||||||
caches: Sequence<IncrementalCacheImpl<*>>
|
|
||||||
): Set<FqName> {
|
|
||||||
val types = LinkedList(listOf(typeFqName))
|
|
||||||
val subtypes = hashSetOf<FqName>()
|
|
||||||
|
|
||||||
while (types.isNotEmpty()) {
|
|
||||||
val unprocessedType = types.pollFirst()
|
|
||||||
|
|
||||||
caches.flatMap { it.getSubtypesOf(unprocessedType) }
|
|
||||||
.filter { it !in subtypes }
|
|
||||||
.forEach { types.addLast(it) }
|
|
||||||
|
|
||||||
subtypes.add(unprocessedType)
|
|
||||||
}
|
|
||||||
|
|
||||||
return subtypes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLookupTracker(project: JpsProject): LookupTracker {
|
private fun getLookupTracker(project: JpsProject): LookupTracker {
|
||||||
@@ -948,14 +879,6 @@ private fun hasKotlinDirtyOrRemovedFiles(
|
|||||||
return chunk.targets.any { KotlinSourceFileCollector.getRemovedKotlinFiles(dirtyFilesHolder, it).isNotEmpty() }
|
return chunk.targets.any { KotlinSourceFileCollector.getRemovedKotlinFiles(dirtyFilesHolder, it).isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private inline fun <T, R> Iterable<T>.forAllPairs(other: Iterable<R>, fn: (T, R)->Unit) {
|
|
||||||
for (t in this) {
|
|
||||||
for (r in other) {
|
|
||||||
fn(t, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun Logger.debug(message: ()->String) {
|
private inline fun Logger.debug(message: ()->String) {
|
||||||
if (isDebugEnabled) {
|
if (isDebugEnabled) {
|
||||||
debug(message())
|
debug(message())
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class JpsIncrementalCacheImpl(
|
|||||||
for ((className, functions) in dirtyInlineFunctionsMap.getEntries()) {
|
for ((className, functions) in dirtyInlineFunctionsMap.getEntries()) {
|
||||||
val classFilePath = getClassFilePath(className.internalName)
|
val classFilePath = getClassFilePath(className.internalName)
|
||||||
|
|
||||||
for (cache in dependentsWithThis) {
|
for (cache in thisWithDependentCaches) {
|
||||||
val targetFiles = functions.flatMap { (cache as JpsIncrementalCacheImpl).inlinedTo[classFilePath, it] }
|
val targetFiles = functions.flatMap { (cache as JpsIncrementalCacheImpl).inlinedTo[classFilePath, it] }
|
||||||
result.addAll(targetFiles)
|
result.addAll(targetFiles)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-25
@@ -238,6 +238,7 @@ open class KotlinCompile() : AbstractKotlinCompile<K2JVMCompilerArguments>() {
|
|||||||
var currentRemoved = removed
|
var currentRemoved = removed
|
||||||
val allGeneratedFiles = hashSetOf<GeneratedFile<TargetId>>()
|
val allGeneratedFiles = hashSetOf<GeneratedFile<TargetId>>()
|
||||||
val compiledSourcesSet = hashSetOf<File>()
|
val compiledSourcesSet = hashSetOf<File>()
|
||||||
|
val logAction = { logStr: String -> logger.kotlinInfo(logStr) }
|
||||||
|
|
||||||
fun getOrCreateIncrementalCache(target: TargetId): GradleIncrementalCacheImpl {
|
fun getOrCreateIncrementalCache(target: TargetId): GradleIncrementalCacheImpl {
|
||||||
val cacheDir = File(cachesBaseDir, "increCache.${target.name}")
|
val cacheDir = File(cachesBaseDir, "increCache.${target.name}")
|
||||||
@@ -255,15 +256,6 @@ open class KotlinCompile() : AbstractKotlinCompile<K2JVMCompilerArguments>() {
|
|||||||
innerClasses.flatMap { it.findLookupSymbols() }
|
innerClasses.flatMap { it.findLookupSymbols() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Iterable<LookupSymbol>.files(filesFilter: (File) -> Boolean = { true }, logAction: (LookupSymbol, Iterable<File>) -> Unit = { l,fs -> }): Iterable<File> =
|
|
||||||
flatMap { lookup ->
|
|
||||||
val files = lookupStorage.get(lookup).map(::File).filter(filesFilter)
|
|
||||||
if (files.any()) {
|
|
||||||
logAction(lookup, files)
|
|
||||||
}
|
|
||||||
files
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dirtyLookupSymbolsFromRemovedKotlinFiles(): List<LookupSymbol> {
|
fun dirtyLookupSymbolsFromRemovedKotlinFiles(): List<LookupSymbol> {
|
||||||
val removedKotlinFiles = removed.filter { it.isKotlinFile() }
|
val removedKotlinFiles = removed.filter { it.isKotlinFile() }
|
||||||
return if (removedKotlinFiles.isNotEmpty())
|
return if (removedKotlinFiles.isNotEmpty())
|
||||||
@@ -298,11 +290,7 @@ open class KotlinCompile() : AbstractKotlinCompile<K2JVMCompilerArguments>() {
|
|||||||
|
|
||||||
if (lookupSymbols.any()) {
|
if (lookupSymbols.any()) {
|
||||||
val kotlinModifiedFilesSet = modifiedKotlinFiles.toHashSet()
|
val kotlinModifiedFilesSet = modifiedKotlinFiles.toHashSet()
|
||||||
val dirtyFilesFromLookups = lookupSymbols.files(
|
val dirtyFilesFromLookups = mapLookupSymbolsToFiles(lookupStorage, lookupSymbols, logAction, excludes = kotlinModifiedFilesSet)
|
||||||
filesFilter = { it !in kotlinModifiedFilesSet },
|
|
||||||
logAction = { lookup, files ->
|
|
||||||
logger.kotlinInfo("changes in ${lookup.name} (${lookup.scope}) causes recompilation of ${files.joinToString { projectRelativePath(it) }}")
|
|
||||||
})
|
|
||||||
modifiedKotlinFiles.addAll(dirtyFilesFromLookups)
|
modifiedKotlinFiles.addAll(dirtyFilesFromLookups)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,9 +416,9 @@ open class KotlinCompile() : AbstractKotlinCompile<K2JVMCompilerArguments>() {
|
|||||||
val (exitCode, generatedFiles) = compileChanged(targets, existingSource.toSet(), outputDir, args, ::getIncrementalCache, lookupTracker)
|
val (exitCode, generatedFiles) = compileChanged(targets, existingSource.toSet(), outputDir, args, ::getIncrementalCache, lookupTracker)
|
||||||
|
|
||||||
allGeneratedFiles.addAll(generatedFiles)
|
allGeneratedFiles.addAll(generatedFiles)
|
||||||
val changes = updateIncrementalCaches(targets, generatedFiles,
|
val compilationResult = updateIncrementalCaches(targets, generatedFiles,
|
||||||
compiledWithErrors = exitCode != ExitCode.OK,
|
compiledWithErrors = exitCode != ExitCode.OK,
|
||||||
getIncrementalCache = { caches[it]!! })
|
getIncrementalCache = { caches[it]!! })
|
||||||
|
|
||||||
lookupStorage.update(lookupTracker, sourcesToCompile, currentRemoved)
|
lookupStorage.update(lookupTracker, sourcesToCompile, currentRemoved)
|
||||||
allCachesVersions().forEach { it.saveIfNeeded() }
|
allCachesVersions().forEach { it.saveIfNeeded() }
|
||||||
@@ -439,14 +427,8 @@ open class KotlinCompile() : AbstractKotlinCompile<K2JVMCompilerArguments>() {
|
|||||||
if (!isIncrementalDecided) break;
|
if (!isIncrementalDecided) break;
|
||||||
|
|
||||||
compiledSourcesSet.addAll(sourcesToCompile)
|
compiledSourcesSet.addAll(sourcesToCompile)
|
||||||
|
val (dirtyLookupSymbols, dirtyClassFqNames) = compilationResult.getDirtyData(caches.values, logAction)
|
||||||
val dirtyLookups = changes.dirtyLookups<TargetId>(caches.values.asSequence())
|
sourcesToCompile = mapLookupSymbolsToFiles(lookupStorage, dirtyLookupSymbols, logAction, excludes = compiledSourcesSet)
|
||||||
val newDirtyFiles = dirtyLookups.files(
|
|
||||||
filesFilter = { it !in compiledSourcesSet },
|
|
||||||
logAction = { lookup, files ->
|
|
||||||
logger.kotlinInfo("changes in ${lookup.name} (${lookup.scope}) causes recompilation of ${files.joinToString { projectRelativePath(it) }}")
|
|
||||||
})
|
|
||||||
sourcesToCompile = newDirtyFiles.filter { it in sources }.toSet()
|
|
||||||
|
|
||||||
if (currentRemoved.any()) {
|
if (currentRemoved.any()) {
|
||||||
currentRemoved = listOf()
|
currentRemoved = listOf()
|
||||||
|
|||||||
Reference in New Issue
Block a user