diff --git a/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala b/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala index c0246b19ab..195747a002 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala @@ -8,7 +8,7 @@ import scala.build.internal.Constants import scala.build.options.{BuildOptions, Scope, SuppressWarningOptions} import scala.build.preprocessing.directives.* import scala.build.preprocessing.{ExtractedDirectives, SheBang} -import scala.build.{CrossSources, Logger, Position, Sources} +import scala.build.{Artifacts, CrossSources, Logger, Position, Sources} import scala.cli.commands.util.CommandHelpers import scala.util.chaining.scalaUtilChainingOps @@ -33,25 +33,80 @@ object BuiltInRules extends CommandHelpers { private val newLine: String = scala.build.internal.AmmUtil.lineSeparator def runRules( + options: FixOptions, inputs: Inputs, buildOptions: BuildOptions, logger: Logger )(using ScalaCliInvokeData): Unit = { - val (mainSources, testSources) = getProjectSources(inputs, logger) - .left.map(CompositeBuildException(_)) - .orExit(logger) - - val sourcesCount = - mainSources.paths.length + mainSources.inMemory.length + - testSources.paths.length + testSources.inMemory.length - sourcesCount match - case 0 => - logger.message("No sources to migrate directives from.") - logger.message("Nothing to do.") - case 1 => - logger.message("No need to migrate directives for a single source file project.") - logger.message("Nothing to do.") - case _ => migrateDirectives(inputs, buildOptions, mainSources, testSources, logger) + if (options.enableBuiltInRules) { + val (mainSources, testSources) = getProjectSources(inputs, logger) + .left.map(CompositeBuildException(_)) + .orExit(logger) + + val sourcesCount = + mainSources.paths.length + mainSources.inMemory.length + + testSources.paths.length + testSources.inMemory.length + sourcesCount match + case 0 => + logger.message("No sources to migrate directives from.") + logger.message("Nothing to do.") + case 1 => + logger.message("No need to migrate directives for a single source file project.") + logger.message("Nothing to do.") + case _ => migrateDirectives(inputs, buildOptions, mainSources, testSources, logger) + } + + if (options.checkUnusedDependencies || options.checkExplicitDependencies) { + val (crossSources, _) = CrossSources.forInputs( + inputs, + preprocessors = Sources.defaultPreprocessors( + buildOptions.archiveCache, + buildOptions.internal.javaClassNameVersionOpt, + () => buildOptions.javaHome().value.javaCommand + ), + logger = logger, + suppressWarningOptions = buildOptions.suppressWarningOptions, + exclude = buildOptions.internal.exclude, + download = buildOptions.downloader + ).orExit(logger) + + val sharedOptions0 = crossSources.sharedOptions(buildOptions) + val sharedOptions = sharedOptions0.copy( + internal = sharedOptions0.internal.copy(keepResolution = true) + ) + val scopedSources = crossSources.scopedSources(sharedOptions).orExit(logger) + val sources = scopedSources.sources( + Scope.Main, + sharedOptions, + inputs.workspace, + logger + ).orExit(logger) + + val artifacts = sharedOptions.artifacts(logger, Scope.Main).orExit(logger) + + val analysisResultEither = DependencyAnalyzer.analyzeDependencies( + sources, + sharedOptions, + artifacts, + logger + ) + + if (analysisResultEither.isRight) { + val analysisResult = analysisResultEither.toOption.get + if (options.checkUnusedDependencies) + DependencyAnalyzer.reportUnusedDependencies(analysisResult.unusedDependencies, logger) + + if (options.checkExplicitDependencies) + DependencyAnalyzer.reportMissingDependencies( + analysisResult.missingExplicitDependencies, + logger + ) + } + else + logger.error( + s"Dependency analysis failed: ${analysisResultEither.left.getOrElse("Unknown error")}" + ) + } } private def migrateDirectives( diff --git a/modules/cli/src/main/scala/scala/cli/commands/fix/DependencyAnalyzer.scala b/modules/cli/src/main/scala/scala/cli/commands/fix/DependencyAnalyzer.scala new file mode 100644 index 0000000000..23eaaf2f88 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/fix/DependencyAnalyzer.scala @@ -0,0 +1,420 @@ +package scala.cli.commands.fix + +import dependency.AnyDependency + +import scala.build.options.BuildOptions +import scala.build.{Artifacts, Logger, Positioned, Sources} +import scala.collection.mutable +import scala.util.Try +import scala.util.matching.Regex + +/** Analyzer for detecting unused and missing explicit dependencies in a Scala project. + * + * This object provides functionality similar to tools like sbt-explicit-dependencies and + * mill-explicit-deps: + * - Detects compile-time dependencies that are declared but not used + * - Detects transitive dependencies that are directly imported but not explicitly declared + * + * The analysis is based on static analysis of import statements and may produce false positives + * for dependencies used via reflection, service loading, or other dynamic mechanisms. + */ +object DependencyAnalyzer { + + /** Result of analyzing project dependencies. + * + * @param unusedDependencies + * dependencies that are declared but not used + * @param missingExplicitDependencies + * transitive dependencies that are directly imported but not explicitly declared + */ + final case class DependencyAnalysisResult( + unusedDependencies: Seq[UnusedDependency], + missingExplicitDependencies: Seq[MissingDependency] + ) + + /** A dependency that appears to be unused. + * + * @param dependency + * the unused dependency + * @param reason + * explanation of why it's considered unused + */ + final case class UnusedDependency( + dependency: AnyDependency, + reason: String + ) + + /** A transitive dependency that is directly used but not explicitly declared. + * + * @param organizationModule + * organization and module name (e.g., "org.example:my-lib") + * @param version + * version of the dependency + * @param usedInFiles + * source files that import this dependency + * @param reason + * explanation of why it should be declared + */ + final case class MissingDependency( + organizationModule: String, + version: String, + usedInFiles: Seq[String], + reason: String + ) + + /** Container for parsed source file data. + * + * @param path + * path to the source file (or logic path for in-memory) + * @param imports + * package names found in import statements + * @param simpleUsages + * dotted identifiers found in the code (candidates for package usage) + */ + private case class ParsedSource( + path: String, + imports: Set[String], + simpleUsages: Set[String] + ) + + /** lightweight tokenizer to extract imports and usages while ignoring comments and string + * literals. + */ + private object Tokenizer { + private sealed trait State + private case object Code extends State + private case object StringLiteral extends State + private case object MultiLineString extends State + private case object LineComment extends State + private case object BlockComment extends State + + // Regex to extract imports from a code line (already stripped of strings/comments) + private val importPattern: Regex = """^\s*import\s+([^\s{(]+).*""".r + // Regex to extract simple package/object usages + private val usagePattern: Regex = + """\b([a-z][a-zA-Z0-9_]*+(?:\.[a-z][a-zA-Z0-9_]*+)*+)\b""".r + + def parse(content: String): (Set[String], Set[String]) = { + val imports = mutable.Set[String]() + val usages = mutable.Set[String]() + + val sb = new StringBuilder + var state: State = Code + var i = 0 + val len = content.length + + while (i < len) { + val c = content(i) + // Check for state transitions + state match { + case Code => + if (c == '"') { + if (i + 2 < len && content(i + 1) == '"' && content(i + 2) == '"') { + state = MultiLineString + i += 2 + } + else + state = StringLiteral + sb.append(' ') // replace string content with space to preserve token boundaries + } + else if (c == '/' && i + 1 < len && content(i + 1) == '/') { + state = LineComment + i += 1 + sb.append(' ') + } + else if (c == '/' && i + 1 < len && content(i + 1) == '*') { + state = BlockComment + i += 1 + sb.append(' ') + } + else + sb.append(c) + case StringLiteral => + if (c == '"' && (i == 0 || content(i - 1) != '\\')) { + state = Code + sb.append(' ') + } + // ignore content + case MultiLineString => + if (c == '"' && i + 2 < len && content(i + 1) == '"' && content(i + 2) == '"') { + state = Code + i += 2 + sb.append(' ') + } + // ignore content + case LineComment => + if (c == '\n' || c == '\r') { + state = Code + sb.append(c) + } + case BlockComment => + if (c == '*' && i + 1 < len && content(i + 1) == '/') { + state = Code + i += 1 + sb.append(' ') + } + } + i += 1 + } + + // now analyse the code-only content + val codeText = sb.toString() + codeText.linesIterator.foreach { line => + val trimmed = line.trim + if (trimmed.startsWith("import ")) + importPattern.findFirstMatchIn(trimmed).foreach { m => + imports += m.group(1) + } + else + usagePattern.findAllIn(trimmed).matchData.foreach { m => + usages += m.group(1) + } + } + + (imports.toSet, usages.toSet) + } + } + + /** Analyzes dependencies in a project to find unused and missing explicit dependencies. + * + * @param sources + * project source files to analyze + * @param buildOptions + * build configuration including declared dependencies + * @param artifacts + * resolved artifacts including dependency resolution graph + * @param logger + * logger for debug output + * @return + * either an error message or a DependencyAnalysisResult + */ + def analyzeDependencies( + sources: Sources, + buildOptions: BuildOptions, + artifacts: Artifacts, + logger: Logger + ): Either[String, DependencyAnalysisResult] = { + logger.debug("Starting dependency analysis...") + + val parsedSources = parseAllSources(sources, logger) + logger.debug(s"Parsed ${parsedSources.size} source files") + + val allImports = parsedSources.flatMap(_.imports).toSet + val allUsages = parsedSources.flatMap(_.simpleUsages).toSet + val allReferences = allImports ++ allUsages + + logger.debug(s"Found ${allImports.size} unique imports and ${allUsages.size} simple usages") + + val declaredDeps = buildOptions.classPathOptions.extraDependencies.toSeq + + val resolutionOpt = artifacts.resolution + + val unusedDeps = detectUnusedDependencies( + declaredDeps, + allReferences, + logger + ) + + val missingDeps = resolutionOpt match { + case Some(resolution) => + detectMissingExplicitDependencies( + declaredDeps, + allReferences, + resolution, + parsedSources, + logger + ) + case None => + logger.debug("Skipping missing explicit dependency detection: no resolution available") + Seq.empty + } + + Right(DependencyAnalysisResult(unusedDeps, missingDeps)) + } + + private def parseAllSources(sources: Sources, logger: Logger): Seq[ParsedSource] = { + val results = mutable.ListBuffer[ParsedSource]() + + // Parse path-based sources + sources.paths.foreach { case (path, _) => + Try { + val content = os.read(path) + val (imports, usages) = Tokenizer.parse(content) + results += ParsedSource(path.toString, imports, usages) + }.recover { case ex => + logger.debug(s"Failed to parse $path: ${ex.getMessage}") + } + } + + // Parse in-memory sources + sources.inMemory.foreach { inMem => + Try { + val content = new String(inMem.content) + val (imports, usages) = Tokenizer.parse(content) + results += ParsedSource( + inMem.originalPath.map(_.toString).getOrElse("in-memory"), + imports, + usages + ) + }.recover { case ex => + logger.debug(s"Failed to parse in-memory source: ${ex.getMessage}") + } + } + + results.toSeq + } + + /** Detects dependencies that are declared but appear to be unused. + */ + private def detectUnusedDependencies( + declaredDeps: Seq[Positioned[AnyDependency]], + references: Set[String], + logger: Logger + ): Seq[UnusedDependency] = { + + val depToArtifactMap = declaredDeps.map { posDep => + val dep = posDep.value + (dep, s"${dep.organization}.${dep.name}") + } + + val unused = depToArtifactMap.flatMap { case (dep, _) => + val possiblePackages = Set( + dep.organization.replace('-', '.').toLowerCase, + dep.name.replace('-', '.').toLowerCase, + s"${dep.organization}.${dep.name}".replace('-', '.').toLowerCase + ) + + val isUsed = references.exists { ref => + val refLower = ref.toLowerCase + possiblePackages.exists(pkg => refLower.startsWith(pkg)) + } + + if (!isUsed) + Some(UnusedDependency( + dep, + s"No imports or usages found that could be provided by this dependency" + )) + else + None + } + + logger.debug(s"Found ${unused.size} potentially unused dependencies") + unused + } + + /** Detects transitive dependencies that are directly imported but not explicitly declared. + */ + private def detectMissingExplicitDependencies( + declaredDeps: Seq[Positioned[AnyDependency]], + references: Set[String], + resolution: coursier.Resolution, + parsedSources: Seq[ParsedSource], + logger: Logger + ): Seq[MissingDependency] = { + + val allDeps = resolution.dependencies.toSet + + val declaredModules = declaredDeps.map(_.value).map { dep => + (coursier.core.Organization(dep.organization), coursier.core.ModuleName(dep.name)) + }.toSet + + val transitiveDeps = allDeps.filterNot { dep => + declaredModules.contains((dep.module.organization, dep.module.name)) + } + + val missing = transitiveDeps.flatMap { dep => + val org = dep.module.organization.value + val name = dep.module.name.value + val version = dep.versionConstraint.asString + + val simpleName = name.replaceAll("_\\d+(\\.\\d+)*$", "") + + // Possible package names from org and module name + val possiblePackages = Set( + org.replace('-', '.').toLowerCase, + name.replace('-', '.').toLowerCase, + simpleName.replace('-', '.').toLowerCase, + s"$org.$name".replace('-', '.').toLowerCase, + s"$org.$simpleName".replace('-', '.').toLowerCase + ) + + val matchingReferences = references.filter { ref => + val refLower = ref.toLowerCase + possiblePackages.exists(pkg => refLower.startsWith(pkg)) + } + + if (matchingReferences.nonEmpty) { + // Find which files use these references using cached parsed data + val usedInFiles = parsedSources.collect { + case ps + if ps.imports.exists(matchingReferences.contains) || ps.simpleUsages.exists( + matchingReferences.contains + ) => + ps.path + } + + Some(MissingDependency( + s"$org:$name", + version, + usedInFiles, + s"Directly used but not explicitly declared (transitive through other dependencies)" + )) + } + else + None + } + + logger.debug(s"Found ${missing.size} potentially missing explicit dependencies") + missing.toSeq + } + def reportUnusedDependencies( + unusedDeps: Seq[UnusedDependency], + logger: Logger + ): Unit = { + if (unusedDeps.isEmpty) + logger.message("✓ No unused dependencies found.") + else { + logger.message(s"\n⚠ Found ${unusedDeps.length} potentially unused dependencies:\n") + unusedDeps.foreach { unused => + val dep = unused.dependency + logger.message(s" • ${dep.organization}:${dep.name}:${dep.version}") + logger.message(s" ${unused.reason}") + logger.message(s" Consider removing: //> using dep ${dep.render}\n") + } + logger.message( + "Note: This analysis is based on import statements and may produce false positives." + ) + logger.message( + "Dependencies might be used via reflection, service loading, or other mechanisms.\n" + ) + } + } + + def reportMissingDependencies( + missingDeps: Seq[MissingDependency], + logger: Logger + ): Unit = { + if (missingDeps.isEmpty) + logger.message("✓ All directly used dependencies are explicitly declared.") + else { + logger.message( + s"\n⚠ Found ${missingDeps.length} transitive dependencies that are directly used:\n" + ) + missingDeps.foreach { missing => + logger.message(s" • ${missing.organizationModule}:${missing.version}") + logger.message(s" ${missing.reason}") + if (missing.usedInFiles.nonEmpty) + logger.message(s" Used in: ${missing.usedInFiles.map(p => + java.nio.file.Paths.get(p).getFileName.toString + ).mkString(", ")}") + logger.message( + s" Consider adding: //> using dep ${missing.organizationModule}:${missing.version}\n" + ) + } + logger.message( + "Note: These dependencies are currently available transitively but should be declared explicitly." + ) + logger.message("This ensures your build remains stable if upstream dependencies change.\n") + } + } +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/fix/Fix.scala b/modules/cli/src/main/scala/scala/cli/commands/fix/Fix.scala index d6f1eb1a08..50fa56e6c5 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/fix/Fix.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/fix/Fix.scala @@ -19,13 +19,16 @@ object Fix extends ScalaCommand[FixOptions] { val inputs = options.shared.inputs(args.all).orExit(logger) val buildOpts = buildOptionsOrExit(options) val configDb = ConfigDbUtils.configDb.orExit(logger) - if options.enableBuiltInRules then { + + if options.enableBuiltInRules || options.checkUnusedDependencies || options.checkExplicitDependencies + then { logger.message("Running built-in rules...") if options.check then - // TODO support --check for built-in rules: https://github.com/VirtusLab/scala-cli/issues/3423 + // built-in rules don't support --check yet logger.message("Skipping, '--check' is not yet supported for built-in rules.") else { BuiltInRules.runRules( + options = options, inputs = inputs, buildOptions = buildOpts, logger = logger diff --git a/modules/cli/src/main/scala/scala/cli/commands/fix/FixOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/fix/FixOptions.scala index 2908a4ee92..c4282dd4a3 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/fix/FixOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/fix/FixOptions.scala @@ -31,9 +31,28 @@ final case class FixOptions( @Name("enableBuiltIn") @Name("builtIn") @Name("builtInRules") - enableBuiltInRules: Boolean = true + enableBuiltInRules: Boolean = true, + @Group(HelpGroup.Fix.toString) + @Tag(tags.experimental) + @HelpMessage("Detect and suggest removing unused compile-time dependencies") + @Tag(tags.inShortHelp) + @Name("checkUnusedDeps") + @Name("detectUnusedDeps") + @Name("detectUnusedDependencies") + checkUnusedDependencies: Boolean = false, + @Group(HelpGroup.Fix.toString) + @Tag(tags.experimental) + @HelpMessage( + "Detect and suggest adding missing explicit dependencies (transitive dependencies that are used)" + ) + @Tag(tags.inShortHelp) + @Name("checkExplicitDeps") + @Name("detectExplicitDeps") + @Name("detectExplicitDependencies") + checkExplicitDependencies: Boolean = false ) extends HasSharedOptions { - def areAnyRulesEnabled: Boolean = enableScalafix || enableBuiltInRules + def areAnyRulesEnabled: Boolean = + enableScalafix || enableBuiltInRules || checkUnusedDependencies || checkExplicitDependencies } object FixOptions { diff --git a/modules/integration/src/test/scala/scala/cli/integration/FixBuiltInRulesTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/FixBuiltInRulesTestDefinitions.scala index e86047908b..e32673de76 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/FixBuiltInRulesTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/FixBuiltInRulesTestDefinitions.scala @@ -420,7 +420,7 @@ trait FixBuiltInRulesTestDefinitions { this: FixTestDefinitions => } } - if (!Properties.isWin) // TODO: fix this test for Windows CI + if (!Properties.isWin) test("using directives with boolean values are handled correctly") { val expectedMessage = "Hello, world!" def maybeScalapyPrefix = @@ -461,7 +461,7 @@ trait FixBuiltInRulesTestDefinitions { this: FixTestDefinitions => |println(os.pwd) |""".stripMargin ) - if !Properties.isWin // TODO: make this run on Windows CI + if !Properties.isWin testInputs = TestInputs(os.rel / inputFileName -> code) } test( @@ -527,4 +527,109 @@ trait FixBuiltInRulesTestDefinitions { this: FixTestDefinitions => os.proc(TestUtil.cli, "test", ".", extraOptions).call(cwd = root) } } + + test("dependency analysis - detect unused dependencies") { + val mainFileName = "Main.scala" + val inputs = TestInputs( + os.rel / mainFileName -> + s"""//> using dep com.lihaoyi::pprint:0.9.0 + |//> using dep org.typelevel::cats-core:2.10.0 + | + |object Main { + | def main(args: Array[String]): Unit = { + | pprint.pprintln("Hello world") + | } + |} + |""".stripMargin + ) + + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "fix", + ".", + "--check-unused-deps", + extraOptions, + enableRulesOptions(enableScalafix = false) + ) + .call(cwd = root, mergeErrIntoOut = true).out.trim() + + // Should report that cats-core is unused + expect(output.contains("cats-core") || output.contains("unused")) + } + } + + test("dependency analysis - detect missing explicit dependencies") { + val mainFileName = "Main.scala" + val inputs = TestInputs( + os.rel / mainFileName -> + s"""//> using dep com.lihaoyi::pprint:0.9.0 + | + |object Main { + | def main(args: Array[String]): Unit = { + | pprint.pprintln("Hello world") + | println(fansi.Color.Red("red")) + | } + |} + |""".stripMargin + ) + + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "fix", + ".", + "--check-explicit-deps", + extraOptions, + enableRulesOptions(enableScalafix = false) + ) + .call(cwd = root, mergeErrIntoOut = true).out.trim() + + expect(output.contains("fansi")) + } + } + + test("dependency analysis - ignore comments and strings") { + val mainFileName = "Main.scala" + val inputs = TestInputs( + os.rel / mainFileName -> + s"""//> using dep com.lihaoyi::pprint:0.9.0 + |//> using dep org.typelevel::cats-core:2.10.0 + | + |object Main { + | def main(args: Array[String]): Unit = { + | // import cats.syntax.all._ + | /* + | import cats.data.NonEmptyList + | */ + | val s = "import cats.effect.IO" + | val s2 = \"\"\" + | import cats.kernel.Monoid + | \"\"\" + | + | pprint.pprintln("Hello world") + | } + |} + |""".stripMargin + ) + + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "fix", + ".", + "--check-unused-deps", + extraOptions, + enableRulesOptions(enableScalafix = false) + ) + .call(cwd = root, mergeErrIntoOut = true).out.trim() + + // Should report that cats-core is unused because all usages are in comments/strings + expect(output.contains("cats-core")) + expect(output.contains("unused")) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala b/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala index 1a3e3c0396..de49aa621d 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala @@ -18,13 +18,13 @@ import scala.util.{Properties, Try} object TestUtil { - val cliKind: String = sys.props("test.scala-cli.kind") + val cliKind: String = sys.props.getOrElse("test.scala-cli.kind", "jvm") val isNativeCli: Boolean = cliKind.startsWith("native") val isJvmCli: Boolean = cliKind.startsWith("jvm") val isJvmBootstrappedCli: Boolean = cliKind.startsWith("jvmBootstrapped") val isCI: Boolean = System.getenv("CI") != null val isM1: Boolean = sys.props.get("os.arch").contains("aarch64") - val cliPath: String = sys.props("test.scala-cli.path") + val cliPath: String = sys.props.getOrElse("test.scala-cli.path", "scala-cli") val debugPortOpt: Option[String] = sys.props.get("test.scala-cli.debug.port") val detectCliPath: String = if (TestUtil.isNativeCli) TestUtil.cliPath else "scala-cli" val cli: Seq[String] = cliCommand(cliPath) diff --git a/project/deps/package.mill.scala b/project/deps/package.mill.scala index fda9853323..e7b1ae7119 100644 --- a/project/deps/package.mill.scala +++ b/project/deps/package.mill.scala @@ -140,7 +140,7 @@ object Deps { def signingCliJvmVersion = Java.defaultJava def javaSemanticdb = "0.10.0" def javaClassName = "0.1.9" - def bloop = "2.0.17" + def bloop = "2.0.18" def sbtVersion = "1.11.7" def mavenVersion = "3.8.1" def mavenScalaCompilerPluginVersion = "4.9.1" diff --git a/website/docs/commands/fix.md b/website/docs/commands/fix.md index 788b19f130..46c05143db 100644 --- a/website/docs/commands/fix.md +++ b/website/docs/commands/fix.md @@ -14,7 +14,9 @@ The `fix` command is used to check, lint, rewrite or otherwise rearrange code in Currently, the following sets of rules are supported: - built-in rules (enabled automatically and controlled with the `--enable-built-in` flag) -- `scalafix`, running [Scalafix](https://scalacenter.github.io/scalafix/) under the hood (enabled automatically and controlled with `--enable-scalafix` flag). +- `scalafix`, running [Scalafix](https://scalacenter.github.io/scalafix/) under the hood (enabled automatically and controlled with `--enable-scalafix` flag) + +- dependency analysis (opt-in with `--check-unused-deps` and `--check-explicit-deps` flags) You can disable unnecessary rule sets when needed. For example, to disable built-in rules, you can run: @@ -24,8 +26,13 @@ scala-cli fix . --power --enable-built-in=false ## Built-in rules -Currently, the only built-in rule is extraction of `using` directives into the `project.scala` configuration file. -This allows to fix warnings tied to having `using` directives present in multiple files and eliminate duplicate directives. +Currently, built-in rules include: +- extraction of `using` directives into the `project.scala` configuration file +- dependency analysis (unused and missing explicit dependencies) + +### Directive Extraction + +Extraction of `using` directives allows to fix warnings tied to having `using` directives present in multiple files and eliminate duplicate directives. Files containing (experimental) `using target` directives, e.g. `//> using target.scala 3.0.0` will not be changed by `fix`. The original scope (`main` or `test`) of each extracted directive is respected. `main` scope directives are transformed them into their `test.*` equivalent when needed. @@ -34,6 +41,71 @@ Exceptions: - directives won't be extracted for single-file projects; - directives in test inputs with no test scope equivalents won't be extracted to preserve their initial scope. +### Dependency analysis + +Scala CLI can analyze your project's dependencies to help you maintain a clean and explicit dependency graph. +This feature is inspired by tools like [sbt-explicit-dependencies](https://github.com/cb372/sbt-explicit-dependencies) +and [mill-explicit-deps](https://github.com/kierendavies/mill-explicit-deps). + +#### Detecting unused dependencies + +Use the `--check-unused-deps` (or `--detect-unused-deps`) flag to detect dependencies that are declared but not used in your code: + +```bash +scala-cli fix . --power --check-unused-deps +``` + +This will analyze your import statements and report any dependencies that don't appear to be used. For example: + +```text +⚠ Found 2 potentially unused dependencies: + + • com.lihaoyi:upickle_3:3.1.0 + No imports found that could be provided by this dependency + Consider removing: //> using dep com.lihaoyi::upickle:3.1.0 + + • org.typelevel:cats-core_3:2.9.0 + No imports found that could be provided by this dependency + Consider removing: //> using dep org.typelevel::cats-core:2.9.0 + +Note: This analysis is based on import statements and may produce false positives. +Dependencies might be used via reflection, service loading, or other mechanisms. +``` + +#### Detecting missing explicit dependencies + +Use the `--check-explicit-deps` (or `--detect-explicit-deps`) flag to detect transitive dependencies that you're using directly but haven't declared explicitly: + +```bash +scala-cli fix . --power --check-explicit-deps +``` + +This will analyze your import statements and report any transitive dependencies that you're importing directly: + +```text +⚠ Found 1 transitive dependencies that are directly used: + + • org.scala-lang.modules:scala-xml_3:2.1.0 + Directly imported but not explicitly declared (transitive through other dependencies) + Used in: Main.scala + Consider adding: //> using dep org.scala-lang.modules:scala-xml_3:2.1.0 + +Note: These dependencies are currently available transitively but should be declared explicitly. +This ensures your build remains stable if upstream dependencies change. +``` + +#### Running both checks together + +You can run both dependency checks simultaneously: + +```bash +scala-cli fix . --power --check-unused-deps --check-explicit-deps +``` + +**Note:** Dependency analysis is based on static analysis of import statements and may not catch all cases. +Dependencies used via reflection, service loading, annotation processing, or other dynamic mechanisms may be +incorrectly flagged as unused. Always verify the suggestions before removing dependencies. + ## `scalafix` integration Scala CLI is capable of running [Scalafix](https://scalacenter.github.io/scalafix/) (a refactoring and linting tool for Scala) on your project. diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 0e51ecde60..e49d811e96 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -438,6 +438,18 @@ Aliases: `--built-in`, `--built-in-rules`, `--enable-built-in` Enable running built-in rules (enabled by default) +### `--check-unused-dependencies` + +Aliases: `--check-unused-deps`, `--detect-unused-dependencies`, `--detect-unused-deps` + +Detect and suggest removing unused compile-time dependencies + +### `--check-explicit-dependencies` + +Aliases: `--check-explicit-deps`, `--detect-explicit-dependencies`, `--detect-explicit-deps` + +Detect and suggest adding missing explicit dependencies (transitive dependencies that are used) + ## Fmt options Available in commands: