From aeabb90d2d34293dccf8dcff69c9cb023cfe3d4f Mon Sep 17 00:00:00 2001 From: Saber <157775043+saber04414@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:16:40 -0800 Subject: [PATCH] [2.x] feat: dependencyLicenseInfo (#8506) * Add JSON output support to dependencyLicenseInfo - Add LicenseInfo rendering object with text and JSON output - Add dependencyLicenseInfo input task key - Implement dependencyLicenseInfo task with JSON format support - Supports --out option for file output - Auto-detects JSON format from .json file extension - Follows same pattern as dependencyTree task Resolves #7771 --- .../graph/rendering/LicenseInfo.scala | 50 +++++++++++++++++++ .../sbt/plugins/DependencyTreeKeys.scala | 2 + .../sbt/plugins/DependencyTreeSettings.scala | 44 ++++++++++++++++ .../dependency-graph/license-info/build.sbt | 30 +++++++++++ .../dependency-graph/license-info/test | 8 +++ 5 files changed, 134 insertions(+) create mode 100644 main/src/main/scala/sbt/internal/graph/rendering/LicenseInfo.scala create mode 100644 sbt-app/src/sbt-test/dependency-graph/license-info/build.sbt create mode 100644 sbt-app/src/sbt-test/dependency-graph/license-info/test diff --git a/main/src/main/scala/sbt/internal/graph/rendering/LicenseInfo.scala b/main/src/main/scala/sbt/internal/graph/rendering/LicenseInfo.scala new file mode 100644 index 000000000..b98942488 --- /dev/null +++ b/main/src/main/scala/sbt/internal/graph/rendering/LicenseInfo.scala @@ -0,0 +1,50 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal +package graph +package rendering + +import sbt.internal.graph.* + +object LicenseInfo { + def render(graph: ModuleGraph): String = + graph.nodes + .filter(_.isUsed) + .groupBy(_.license) + .toSeq + .sortBy(_._1) + .map { case (license, modules) => + license.getOrElse("No license specified") + "\n" + + modules.map(m => s"\t ${m.id.idString}").mkString("\n") + } + .mkString("\n\n") + + def renderJson(graph: ModuleGraph): String = { + // Create JSON array manually: [{license: "...", modules: [...]}, ...] + def escapeJson(str: String): String = + str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") + + def formatModuleList(modules: Vector[String]): String = + modules.map(m => s""""${escapeJson(m)}"""").mkString("[", ",", "]") + + val groups = graph.nodes + .filter(_.isUsed) + .groupBy(_.license) + .toSeq + .sortBy(_._1) + .map { case (license, modules) => + val licenseStr = license.getOrElse("No license specified") + val moduleList = formatModuleList(modules.map(_.id.idString).toVector.sorted) + s"""{"license":"${escapeJson(licenseStr)}","modules":$moduleList}""" + } + + groups.mkString("[", ",", "]") + } +} diff --git a/main/src/main/scala/sbt/plugins/DependencyTreeKeys.scala b/main/src/main/scala/sbt/plugins/DependencyTreeKeys.scala index 68b73539e..01b9e1880 100644 --- a/main/src/main/scala/sbt/plugins/DependencyTreeKeys.scala +++ b/main/src/main/scala/sbt/plugins/DependencyTreeKeys.scala @@ -34,6 +34,8 @@ abstract class DependencyTreeKeys: private[sbt] val dependencyTreeModuleGraphStore = taskKey[ModuleGraph]("The stored module-graph from the last run") val whatDependsOn = inputKey[String]("Shows information about what depends on the given module") + val dependencyLicenseInfo = + inputKey[String]("Displays license information for dependencies in text or JSON format") private[sbt] val dependencyTreeCrossProjectId = settingKey[ModuleID]("") // 0 was added to avoid conflict with sbt-dependency-tree diff --git a/main/src/main/scala/sbt/plugins/DependencyTreeSettings.scala b/main/src/main/scala/sbt/plugins/DependencyTreeSettings.scala index 8f7fdb2ef..0f14c7dcb 100644 --- a/main/src/main/scala/sbt/plugins/DependencyTreeSettings.scala +++ b/main/src/main/scala/sbt/plugins/DependencyTreeSettings.scala @@ -254,8 +254,52 @@ OPTIONS } output }, + dependencyLicenseInfo := (Def.inputTaskDyn { + val s = streams.value + val args = ArgsParser.parsed.toList + val isHelp = args.contains(Arg.Help) + val isQuiet = args.contains(Arg.Quiet) + if isHelp then Def.task { s.log.info(licenseInfoUsageText); "" } + else + val formatOpt = (args + .collect { case Arg.Format(fmt) => fmt }) + .reverse + .headOption + val outFileNameOpt = (args + .collect { case Arg.Out(out) => out }) + .reverse + .headOption + val outFileOpt = outFileNameOpt.map(new File(_)) + val format = (formatOpt, outFileNameOpt) match + case (None, Some(out)) if out.endsWith(".json") => Fmt.Json + case (Some(fmt), _) => fmt + case _ => Fmt.Tree + Def.task { + val graph = dependencyTreeModuleGraph0.value + val output = format match + case Fmt.Json => rendering.LicenseInfo.renderJson(graph) + case _ => rendering.LicenseInfo.render(graph) + handleOutput(output, outFileOpt, isQuiet, s.log) + } + }).evaluated, ) + def licenseInfoUsageText: String = + s"""dependencyLicenseInfo task displays license information for dependencies. + +USAGE + dependencyLicenseInfo [subcommand] [options] + +SUBCOMMAND + json Prints JSON (default is text) + help Prints this help + +OPTIONS + --quiet Returns the output as task value + --out Writes the output to the specified file; + The file extension will influence the default subcommand +""" + private def handleOutput( content: String, outputFileOpt: Option[File], diff --git a/sbt-app/src/sbt-test/dependency-graph/license-info/build.sbt b/sbt-app/src/sbt-test/dependency-graph/license-info/build.sbt new file mode 100644 index 000000000..257d3c5bf --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-graph/license-info/build.sbt @@ -0,0 +1,30 @@ +name := "license-info-test" +scalaVersion := "3.3.1" + +libraryDependencies += "org.scala-lang" % "scala-library" % scalaVersion.value + +TaskKey[Unit]("check") := { + import java.io.File + import sbt.io.IO + + // Check text file + val textFile = new File("target/licenses.txt") + require(textFile.exists(), s"Text file ${textFile.getPath} does not exist") + val textContent = IO.read(textFile) + require(textContent.nonEmpty, "Text file is empty") + require(textContent.contains("scala-library") || textContent.contains("No license specified"), + "Text file should contain license information") + + // Check JSON file + val jsonFile = new File("target/licenses.json") + require(jsonFile.exists(), s"JSON file ${jsonFile.getPath} does not exist") + val jsonContent = IO.read(jsonFile) + require(jsonContent.nonEmpty, "JSON file is empty") + require(jsonContent.trim.startsWith("[") && jsonContent.trim.endsWith("]"), + "JSON file should be a valid JSON array") + require(jsonContent.contains("\"license\"") && jsonContent.contains("\"modules\""), + "JSON file should contain license and modules fields") + + () +} + diff --git a/sbt-app/src/sbt-test/dependency-graph/license-info/test b/sbt-app/src/sbt-test/dependency-graph/license-info/test new file mode 100644 index 000000000..fc562376e --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-graph/license-info/test @@ -0,0 +1,8 @@ +# Test dependencyLicenseInfo text output +> dependencyLicenseInfo --out target/licenses.txt +$ exists target/licenses.txt +# Test dependencyLicenseInfo JSON output +> dependencyLicenseInfo json --out target/licenses.json +$ exists target/licenses.json +> check +