Skip to content
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
146 changes: 146 additions & 0 deletions common/src/main/org/jetbrains/lincheck/util/FileSystem.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* Lincheck
*
* Copyright (C) 2019 - 2025 JetBrains s.r.o.
*
* This Source Code Form is subject to the terms of the
* Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed
* with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

package org.jetbrains.lincheck.util

import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes


// ===== Project package discovery =====

fun computeProjectPackages(root: Path, excludeDirPaths: List<Path> = emptyList()): List<String> {
val files = mutableListOf<Path>()

// Directories to skip fully during traversal
val skipDirNames = setOf(
"build", "out", "node_modules", "target", "dist", "bin"
)

// Normalize excludes: resolve relative paths against root, then normalize & toAbsolutePath
val normalizedExcludes: List<Path> = excludeDirPaths
.mapNotNull { p ->
try {
val resolved = if (p.isAbsolute) p else root.resolve(p)
resolved.toAbsolutePath().normalize()
} catch (_: Exception) { null }
}

Files.walkFileTree(
root,
object : SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
val name = dir.fileName?.toString() ?: return FileVisitResult.CONTINUE
// Skip common build/hidden and user-provided excludes
if (name.startsWith('.') || name in skipDirNames) return FileVisitResult.SKIP_SUBTREE
val abs = try { dir.toAbsolutePath().normalize() } catch (_: Exception) { dir }
for (ex in normalizedExcludes) {
// If this directory is the excluded dir or lies under it — skip the subtree
if (abs == ex || abs.startsWith(ex)) return FileVisitResult.SKIP_SUBTREE
}
return FileVisitResult.CONTINUE
}

override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
val n = file.fileName.toString()
if (n.endsWith(".kt") || n.endsWith(".java")) files.add(file)
return FileVisitResult.CONTINUE
}
}
)

val maxLinesRead = 300
val packages = mutableListOf<String>()
for (file in files) {
val pkg = readPackageName(file, maxLinesRead) ?: continue
if (pkg.isNotEmpty()) packages.add(pkg)
}
return compressPackages(packages)
}

private fun compressPackages(packages: List<String>): List<String> {
if (packages.isEmpty()) return emptyList()

// Minimize: keep only top-most packages (skip subpackages of already added ones)
val sorted = packages.sortedBy { it.length }
val result = mutableSetOf<String>()
for (p in sorted) {
if (p in result || result.any { p.startsWith("$it.") }) continue
result.add(p)
}
return result.toList()
}

private fun readPackageName(path: Path, maxLinesRead: Int = -1): String? {
return try {
Files.newBufferedReader(path).use { br ->
var count = 0
while (true) {
val line = br.readLine() ?: break
count++
parsePackageFromLine(line)?.let { return it }
if (maxLinesRead != -1 && count >= maxLinesRead) break
}
null
}
} catch (_: Exception) {
null
}
}

private fun parsePackageFromLine(rawLine: String): String? {
var i = 0

fun skipSpaces() {
while (i < rawLine.length && rawLine[i].isWhitespace()) i++
}

// Skip leading spaces
skipSpaces()
// Quickly ignore comment or annotation lines
if (i < rawLine.length && (rawLine[i] == '/' || rawLine[i] == '@')) return null

// Expect keyword "package"
val kw = "package"
if (i + kw.length > rawLine.length) return null
if (!rawLine.regionMatches(i, kw, 0, kw.length, ignoreCase = false)) return null
i += kw.length
if (i < rawLine.length && !rawLine[i].isWhitespace()) return null // must be followed by space
skipSpaces()

// Parse qualified name: Identifier( . Identifier )*
val start = i

fun isIdStart(ch: Char) = ch == '_' || ch.isLetter()
fun isIdPart(ch: Char) = ch == '_' || ch.isLetterOrDigit()

var expectId = true
var seenChar = false
while (i < rawLine.length) {
val ch = rawLine[i]
if (expectId) {
if (!isIdStart(ch)) break
i++
while (i < rawLine.length && isIdPart(rawLine[i])) i++
expectId = false
seenChar = true
} else {
if (ch != '.') break
i++
expectId = true
}
}
if (!seenChar) return null
val pkg = rawLine.substring(start, i)
return pkg.ifEmpty { null }
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ package org.jetbrains.lincheck.jvm.agent

import org.jetbrains.annotations.TestOnly
import org.jetbrains.lincheck.util.Logger
import org.jetbrains.lincheck.util.computeProjectPackages
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import javax.management.remote.JMXServiceURL
import java.nio.file.Paths

/**
* Parses and stores arguments passed to Lincheck JVM javaagents (trace-recorder and trace-debugger).
Expand All @@ -41,7 +43,7 @@ import javax.management.remote.JMXServiceURL
*
* - exclude — semicolon-separated list of exclude patterns (optional).
* Example: `exclude="org.example.internal.*;**.generated.*"`
*
*
* - lineBreakpoints — semicolon-separated list of line breakpoints to capture snapshots from.
* This is functionality for the live debugger project.
*
Expand All @@ -61,6 +63,24 @@ import javax.management.remote.JMXServiceURL
* * for `text`: `verbose` --- enables verbose output (disabled by default).
* Example: `formatOption=dump`
*
* - projectPath — absolute or relative path to a project root directory. When provided, the agent
* traverses all Kotlin/Java source files under this directory, extracts their package names, and
* builds include patterns from them. Files without a package declaration are ignored. The package
* set is minimized to keep only top-most packages (subpackages are dropped), and each package is
* suffixed with `.*`. These computed patterns are merged with user-provided `include` patterns
* and are calculated during agent initialization.
* Examples:
* - `projectPath=/path/to/your/project`
* - `projectPath=../my-project`
*
* - excludeDirPaths — semicolon-separated list of directory paths to be excluded from traversal
* when `projectPath` is used. Paths can be absolute or relative to `projectPath`. These excludes
* complement the default skips (hidden directories that start with a dot, and common build/output
* folders like `build`, `out`, `node_modules`, `target`, `dist`, `bin`).
* Examples:
* - `excludeDirPaths="build;out;generated"`
* - `excludeDirPaths="/abs/path/to/tools;some/module/build"`
*
* - jmxServer — boolean that enables JMX server for remote monitoring and management, it is off by default.
* The server is available at the URL `service:jmx:rmi:///jndi/rmi://<jmxHost>:<jmxPort>/tracing`.
* Example: `jmxServer=on` or `jmxServer=off`
Expand All @@ -74,6 +94,7 @@ import javax.management.remote.JMXServiceURL
* - rmiPort — port number for RMI registry, defaults to 9998.
* Example: `rmiPort=9998`
*
*
* Quotation rules:
* - Unquoted values may contain any character; use backslash to escape comma (,) and backslash (\\).
* - Values can be enclosed in double quotes ("...") to avoid escaping.
Expand Down Expand Up @@ -111,6 +132,11 @@ object TraceAgentParameters {
const val ARGUMENT_OUTPUT = "output"
const val ARGUMENT_INCLUDE = "include"
const val ARGUMENT_EXCLUDE = "exclude"
const val ARGUMENT_PACK = "pack"
const val ARGUMENT_FORMAT = "format"
const val ARGUMENT_FOPTION = "formatOption"
const val ARGUMENT_PROJECT_PATH = "projectPath"
const val ARGUMENT_EXCLUDE_DIR_PATHS = "excludeDirPaths"
const val ARGUMENT_LINE_BREAKPOINT = "breakpoints"
const val ARGUMENT_JMX_SERVER = "jmxServer"
const val ARGUMENT_JMX_HOST = "jmxHost"
Expand Down Expand Up @@ -152,6 +178,10 @@ object TraceAgentParameters {
@JvmStatic
private val namedArgs: MutableMap<String, String?> = mutableMapOf()

// Cached include patterns computed from projectPath argument, with "*" suffix
@JvmStatic
private var computedProjectIncludePatterns: List<String>? = null

@JvmStatic
fun parseArgs(args: String?, validAdditionalArgs: List<String>) {
if (args == null) {
Expand Down Expand Up @@ -239,6 +269,22 @@ object TraceAgentParameters {
}
}

/**
* Compute include patterns (consisting of source code packages) from the provided project path argument,
* if any, and cache them. This should be invoked during agent initialization.
*/
@JvmStatic
fun computeProjectPackagesIfNeeded() {
val root = namedArgs[ARGUMENT_PROJECT_PATH]?.takeIf { it.isNotBlank() } ?: return
runCatching {
val excludeDirs = splitPatterns(namedArgs[ARGUMENT_EXCLUDE_DIR_PATHS]).map { Paths.get(it) }
computedProjectIncludePatterns = computeProjectPackages(Paths.get(root), excludeDirs).map { "$it.*" }
Logger.warn { "Computed project packages from path '$root': $computedProjectIncludePatterns" }
}.onFailure {
Logger.warn { "Failed to compute project packages from path '$root': ${it.message}" }
}
}

private fun setClassUnderTraceDebuggingToMethodOwner(
startClass: String = classUnderTraceDebugging,
method: String = methodUnderTraceDebugging,
Expand Down Expand Up @@ -269,10 +315,14 @@ object TraceAgentParameters {

@JvmStatic
fun getIncludePatterns(): List<String> = splitPatterns(namedArgs[ARGUMENT_INCLUDE])
.let { userPatterns ->
if (computedProjectIncludePatterns == null) userPatterns
else (userPatterns + computedProjectIncludePatterns!!).distinct()
}

@JvmStatic
fun getExcludePatterns(): List<String> = splitPatterns(namedArgs[ARGUMENT_EXCLUDE])

@JvmStatic
fun getLineBreakpoints(): List<String> = splitPatterns(namedArgs[ARGUMENT_LINE_BREAKPOINT])

Expand Down Expand Up @@ -302,6 +352,7 @@ object TraceAgentParameters {
methodUnderTraceDebugging = ""
traceDumpFilePath = null
namedArgs.clear()
computedProjectIncludePatterns = null
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Lincheck
*
* Copyright (C) 2019 - 2025 JetBrains s.r.o.
*
* This Source Code Form is subject to the terms of the
* Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed
* with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

package org.jetbrains.lincheck.jvm.agent

import org.jetbrains.lincheck.util.computeProjectPackages
import org.junit.Assert
import org.junit.Test
import java.nio.file.Path
import java.nio.file.Paths

class ProjectPackagesTest {
private val projectRoot = Paths.get("..").toAbsolutePath().normalize()
private val actual = listOf(
"sun.nio.ch.lincheck", "org.jetbrains.lincheck", "org.jetbrains.kotlinx.lincheck",
"org.jetbrains.lincheck_test.gpmc", "org.jetbrains.lincheck_test.guide", "org.jetbrains.kotlinx.lincheck_test",
"org.jetbrains.trace.recorder.test.impl", "org.jetbrains.trace.recorder.test.runner", "org.jetbrains.lincheck_test.datastructures"
).also {
// No package in the result should be a subpackage of another
Assert.assertTrue(
"Result must not contain subpackages of already included packages",
it.none { p ->
it.any { other ->
other != p && p.startsWith("$other.")
}
}
)

// All packages follow identifier segments joined by dots
Assert.assertTrue(
"Every package must be a sequence of Java identifiers separated by dots",
it.all { pkg -> isValidPackageName(pkg) }
)
}

@Test
fun computesAllProjectPackages() {
val patterns = computeProjectPackages(projectRoot)
Assert.assertEquals("Include patterns must contain discovered project packages", actual, patterns)
}

@Test
fun computeAllProjectPackagesFilteredDirs() {
val patterns = computeProjectPackages(projectRoot, listOf(Paths.get("./bootstrap")))
Assert.assertEquals(
"Include patterns must contain discovered project packages without packages from 'bootstrap' folder",
actual.filterNot { it == "sun.nio.ch.lincheck" },
patterns
)
}

private fun isValidPackageName(name: String): Boolean {
// Helper functions remain the same
fun isIdStart(ch: Char) = ch == '_' || ch.isLetter()
fun isIdPart(ch: Char) = ch == '_' || ch.isLetterOrDigit()

if (name.isEmpty()) return true

// Split package name by dots and check if each part is valid
val parts = name.split('.')
if (parts.isEmpty()) return false

// Check each package name part
for (part in parts) {
if (part.isEmpty()) return false

// First character must be a letter or underscore
if (!isIdStart(part[0])) return false

// Rest of characters must be letters, digits, or underscores
for (i in 1 until part.length) {
if (!isIdPart(part[i])) return false
}
}

return true
}
}
Loading