diff --git a/ivy/src/main/scala/sbt/CustomPomParser.scala b/ivy/src/main/scala/sbt/CustomPomParser.scala index 668aa5cff..277b50900 100644 --- a/ivy/src/main/scala/sbt/CustomPomParser.scala +++ b/ivy/src/main/scala/sbt/CustomPomParser.scala @@ -30,9 +30,14 @@ final class CustomPomParser(delegate: ModuleDescriptorParser, transform: (Module } object CustomPomParser { + /** The key prefix that indicates that this is used only to store extra information and is not intended for dependency resolution.*/ + val InfoKeyPrefix = "info." + val ApiURLKey = "info.apiURL" + val SbtVersionKey = "sbtVersion" val ScalaVersionKey = "scalaVersion" val ExtraAttributesKey = "extraDependencyAttributes" + private[this] val unqualifiedKeys = Set(SbtVersionKey, ScalaVersionKey, ExtraAttributesKey, ApiURLKey) // packagings that should be jars, but that Ivy doesn't handle as jars val JarPackagings = Set("eclipse-plugin", "hk2-jar", "orbit") @@ -75,8 +80,7 @@ object CustomPomParser } private[this] def artifactExtIncorrect(md: ModuleDescriptor): Boolean = md.getConfigurations.exists(conf => md.getArtifacts(conf.getName).exists(art => JarPackagings(art.getExt))) - private[this] def shouldBeUnqualified(m: Map[String, String]): Map[String, String] = - m.filter { case (SbtVersionKey | ScalaVersionKey | ExtraAttributesKey,_) => true; case _ => false } + private[this] def shouldBeUnqualified(m: Map[String, String]): Map[String, String] = m.filterKeys(unqualifiedKeys) private[this] def condAddExtra(properties: Map[String, String], id: ModuleRevisionId): ModuleRevisionId = if(properties.isEmpty) id else addExtra(properties, id) diff --git a/ivy/src/main/scala/sbt/IvyActions.scala b/ivy/src/main/scala/sbt/IvyActions.scala index ca493963e..66a00f615 100644 --- a/ivy/src/main/scala/sbt/IvyActions.scala +++ b/ivy/src/main/scala/sbt/IvyActions.scala @@ -138,7 +138,7 @@ object IvyActions def processUnresolved(err: ResolveException, log: Logger) { - val withExtra = err.failed.filter(!_.extraAttributes.isEmpty) + val withExtra = err.failed.filter(!_.extraDependencyAttributes.isEmpty) if(!withExtra.isEmpty) { log.warn("\n\tNote: Some unresolved dependencies have extra attributes. Check that these dependencies exist with the requested attributes.") diff --git a/ivy/src/main/scala/sbt/IvyInterface.scala b/ivy/src/main/scala/sbt/IvyInterface.scala index 6a7381831..159c53b7a 100644 --- a/ivy/src/main/scala/sbt/IvyInterface.scala +++ b/ivy/src/main/scala/sbt/IvyInterface.scala @@ -11,11 +11,16 @@ import org.apache.ivy.util.url.CredentialsStore final case class ModuleID(organization: String, name: String, revision: String, configurations: Option[String] = None, isChanging: Boolean = false, isTransitive: Boolean = true, isForce: Boolean = false, explicitArtifacts: Seq[Artifact] = Nil, exclusions: Seq[ExclusionRule] = Nil, extraAttributes: Map[String,String] = Map.empty, crossVersion: CrossVersion = CrossVersion.Disabled) { - override def toString = + override def toString: String = organization + ":" + name + ":" + revision + (configurations match { case Some(s) => ":" + s; case None => "" }) + (if(extraAttributes.isEmpty) "" else " " + extraString) - def extraString = extraAttributes.map { case (k,v) => k + "=" + v } mkString("(",", ",")") + + /** String representation of the extra attributes, excluding any information only attributes. */ + def extraString: String = extraDependencyAttributes.map { case (k,v) => k + "=" + v } mkString("(",", ",")") + + /** Returns the extra attributes except for ones marked as information only (ones that typically would not be used for dependency resolution). */ + def extraDependencyAttributes: Map[String,String] = extraAttributes.filterKeys(!_.startsWith(CustomPomParser.InfoKeyPrefix)) @deprecated("Use `cross(CrossVersion)`, the variant accepting a CrossVersion value constructed by a member of the CrossVersion object instead.", "0.12.0") def cross(v: Boolean): ModuleID = cross(if(v) CrossVersion.binary else CrossVersion.Disabled) diff --git a/main/src/main/scala/sbt/APIMappings.scala b/main/src/main/scala/sbt/APIMappings.scala new file mode 100644 index 000000000..7aa7f9632 --- /dev/null +++ b/main/src/main/scala/sbt/APIMappings.scala @@ -0,0 +1,33 @@ +package sbt + + import java.io.File + import java.net.{MalformedURLException,URL} + +private[sbt] object APIMappings +{ + def extract(cp: Seq[Attributed[File]], log: Logger): Seq[(File,URL)] = + cp.flatMap(entry => extractFromEntry(entry, log)) + + def extractFromEntry(entry: Attributed[File], log: Logger): Option[(File,URL)] = + entry.get(Keys.entryApiURL) match { + case Some(u) => Some( (entry.data, u) ) + case None => entry.get(Keys.moduleID.key).flatMap { mid => extractFromID(entry.data, mid, log) } + } + + private[this] def extractFromID(entry: File, mid: ModuleID, log: Logger): Option[(File,URL)] = + for { + urlString <- mid.extraAttributes.get(CustomPomParser.ApiURLKey) + u <- parseURL(urlString, entry, log) + } yield (entry, u) + + private[this] def parseURL(s: String, forEntry: File, log: Logger): Option[URL] = + try Some(new URL(s)) catch { case e: MalformedURLException => + log.warn("Invalid API base URL '$s' for classpath entry '$forEntry': ${e.toString}") + None + } + + def store[T](attr: Attributed[T], entryAPI: Option[URL]): Attributed[T] = entryAPI match { + case None => attr + case Some(u) => attr.put(Keys.entryApiURL, u) + } +} \ No newline at end of file diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index b2a8e8afd..89a4cec54 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -22,7 +22,7 @@ package sbt import org.apache.ivy.core.module.{descriptor, id} import descriptor.ModuleDescriptor, id.ModuleRevisionId import java.io.File - import java.net.{URI,URL} + import java.net.{URI,URL,MalformedURLException} import java.util.concurrent.Callable import sbinary.DefaultProtocol.StringFormat import Cache.seqFormat @@ -71,6 +71,8 @@ object Defaults extends BuildCommon connectInput :== false, cancelable :== false, sourcesInBase :== true, + autoAPIMappings := false, + apiMappings := Map.empty, autoScalaLibrary :== true, managedScalaInstance :== true, onLoad <<= onLoad ?? idFun[State], @@ -90,6 +92,7 @@ object Defaults extends BuildCommon initialize :== {}, credentials :== Nil, scalaHome :== None, + apiURL := None, javaHome :== None, extraLoggers :== { _ => Nil }, skip :== false, @@ -599,7 +602,8 @@ object Defaults extends BuildCommon def docTaskSettings(key: TaskKey[File] = doc): Seq[Setting[_]] = inTask(key)(compileInputsSettings ++ Seq( target := docDirectory.value, // deprecate docDirectory in favor of 'target in doc'; remove when docDirectory is removed scalacOptions <<= scaladocOptions or scalacOptions, // deprecate scaladocOptions in favor of 'scalacOptions in doc'; remove when scaladocOptions is removed - key in TaskGlobal <<= (compileInputs, target, configuration, streams) map { (in, out, config, s) => + apiMappings ++= { if(autoAPIMappings.value) APIMappings.extract(dependencyClasspath.value, streams.value.log).toMap else Map.empty[File,URL] }, + key in TaskGlobal <<= (compileInputs, target, configuration, apiMappings, streams) map { (in, out, config, xapis, s) => val srcs = in.config.sources val hasScala = srcs.exists(_.name.endsWith(".scala")) val hasJava = srcs.exists(_.name.endsWith(".java")) @@ -607,7 +611,7 @@ object Defaults extends BuildCommon val label = nameForSrc(config.name) val (options, runDoc) = if(hasScala) - (in.config.options, Doc.scaladoc(label, s.cacheDirectory / "scala", in.compilers.scalac)) + (in.config.options ++ Opts.doc.externalAPI(xapis), Doc.scaladoc(label, s.cacheDirectory / "scala", in.compilers.scalac)) else if(hasJava) (in.config.javacOptions, Doc.javadoc(label, s.cacheDirectory / "java", in.compilers.javac)) else @@ -889,7 +893,7 @@ object Classpaths artifactPath in makePom <<= artifactPathSetting(artifact in makePom), publishArtifact in makePom := publishMavenStyle.value && publishArtifact.value, artifact in makePom := Artifact.pom(moduleName.value), - projectID := ModuleID(organization.value, moduleName.value, version.value).cross(crossVersion in projectID value).artifacts(artifacts.value : _*), + projectID <<= defaultProjectID, projectID <<= pluginProjectID, resolvers in GlobalScope :== Nil, projectDescriptors <<= depMap, @@ -938,6 +942,15 @@ object Classpaths log.warn("Multiple resolvers having different access mechanism configured with same name '" + name + "'. To avoid conflict, Remove duplicate project resolvers (`resolvers`) or rename publishing resolver (`publishTo`).") } } + + private[sbt] def defaultProjectID: Initialize[ModuleID] = Def.setting { + val base = ModuleID(organization.value, moduleName.value, version.value).cross(crossVersion in projectID value).artifacts(artifacts.value : _*) + apiURL.value match { + case Some(u) if autoAPIMappings.value => base.extra(CustomPomParser.ApiURLKey -> u.toExternalForm) + case _ => base + } + } + def pluginProjectID: Initialize[ModuleID] = (sbtBinaryVersion in update, scalaBinaryVersion in update, projectID, sbtPlugin) { (sbtBV, scalaBV, pid, isPlugin) => if(isPlugin) sbtPluginExtra(pid, sbtBV, scalaBV) else pid @@ -1088,8 +1101,8 @@ object Classpaths def makeProducts: Initialize[Task[Seq[File]]] = (compile, compileInputs, copyResources) map { (_, i, _) => i.config.classesDirectory :: Nil } def exportProductsTask: Initialize[Task[Classpath]] = - (products.task, packageBin.task, exportJars, compile) flatMap { (psTask, pkgTask, useJars, analysis) => - (if(useJars) Seq(pkgTask).join else psTask) map { _ map { f => analyzed(f, analysis) } } + (products.task, packageBin.task, exportJars, compile, apiURL) flatMap { (psTask, pkgTask, useJars, analysis, u) => + (if(useJars) Seq(pkgTask).join else psTask) map { _ map { f => APIMappings.store(analyzed(f, analysis), u) } } } def constructBuildDependencies: Initialize[BuildDependencies] = diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 2da7e35c6..5ea57220c 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -218,6 +218,10 @@ object Keys val organization = SettingKey[String]("organization", "Organization/group ID.", APlusSetting) val organizationName = SettingKey[String]("organization-name", "Organization full/formal name.", BMinusSetting) val organizationHomepage = SettingKey[Option[URL]]("organization-homepage", "Organization homepage.", BMinusSetting) + val apiURL = SettingKey[Option[URL]]("api-url", "Base URL for API documentation.", BMinusSetting) + val entryApiURL = AttributeKey[URL]("entry-api-url", "Base URL for the API documentation for a classpath entry.") + val apiMappings = TaskKey[Map[File,URL]]("api-mappings", "Mappings from classpath entry to API documentation base URL.", BMinusSetting) + val autoAPIMappings = SettingKey[Boolean]("auto-api-mappings", "If true, automatically manages mappings to the API doc URL.", BMinusSetting) val scmInfo = SettingKey[Option[ScmInfo]]("scm-info", "Basic SCM information for the project.", BMinusSetting) val projectInfo = SettingKey[ModuleInfo]("project-info", "Addition project information like formal name, homepage, licenses etc.", CSetting) val defaultConfiguration = SettingKey[Option[Configuration]]("default-configuration", "Defines the configuration used when none is specified for a dependency.", CSetting) diff --git a/main/src/main/scala/sbt/Opts.scala b/main/src/main/scala/sbt/Opts.scala index ecd10c9c0..cb78f5adc 100644 --- a/main/src/main/scala/sbt/Opts.scala +++ b/main/src/main/scala/sbt/Opts.scala @@ -3,6 +3,9 @@ */ package sbt +import java.io.File +import java.net.URL + /** Options for well-known tasks. */ object Opts { object compile { @@ -15,10 +18,12 @@ object Opts { val verbose = "-verbose" } object doc { - def generator(g: String) = Seq("-doc-generator", g) - def sourceUrl(u: String) = Seq("-doc-source-url", u) - def title(t: String) = Seq("-doc-title", t) - def version(v: String) = Seq("-doc-version", v) + def generator(g: String): Seq[String] = Seq("-doc-generator", g) + def sourceUrl(u: String): Seq[String] = Seq("-doc-source-url", u) + def title(t: String): Seq[String] = Seq("-doc-title", t) + def version(v: String): Seq[String] = Seq("-doc-version", v) + def externalAPI(mappings: Iterable[(File,URL)]): Seq[String] = if(mappings.isEmpty) Nil else + mappings.map{ case (f,u) => s"${f.getAbsolutePath}#${u.toExternalForm}"}.mkString("-doc-external-doc:", ",", "") :: Nil } object resolver { import Path._ diff --git a/sbt/src/sbt-test/actions/external-doc/build.sbt b/sbt/src/sbt-test/actions/external-doc/build.sbt new file mode 100644 index 000000000..bc54fdac0 --- /dev/null +++ b/sbt/src/sbt-test/actions/external-doc/build.sbt @@ -0,0 +1,70 @@ +Seq( + autoAPIMappings in ThisBuild := true, + publishArtifact in (ThisBuild, packageDoc) := false, + publishArtifact in packageSrc := false, + organization in ThisBuild := "org.example", + version := "1.0" +) + +val aPublishResolver = Def.setting { + Resolver.file("a-resolver", baseDirectory.in(ThisBuild).value / "a-repo") +} +val aResolver = Def.setting { + val dir = baseDirectory.in(ThisBuild).value + "a-resolver" at s"file://${dir.getAbsolutePath}/a-repo" +} + +val bResolver = Def.setting { + val dir = baseDirectory.in(ThisBuild).value / "b-repo" + Resolver.file("b-resolver", dir)(Resolver.defaultIvyPatterns) +} + +val apiBaseSetting = apiURL := Some(apiBase(name.value)) +def apiBase(projectName: String) = url(s"http://example.org/${projectName}") +def addDep(projectName: String) = + libraryDependencies += organization.value %% projectName % version.value + + +val checkApiMappings = taskKey[Unit]("Verifies that the API mappings are collected as expected.") + +def expectedMappings = Def.task { + val ms = update.value.configuration(Compile.name).get.modules.flatMap { mod => + mod.artifacts.flatMap { case (a,f) => + val n = a.name.stripSuffix("_" + scalaBinaryVersion.value) + n match { + case "a" | "b" | "c" => (f, apiBase(n)) :: Nil + case _ => Nil + } + } + } + val mc = (classDirectory in (c,Compile)).value -> apiBase("c") + (mc +: ms).toMap +} + + +val a = project.settings( + apiBaseSetting, + publishMavenStyle := true, + publishTo := Some(aPublishResolver.value) +) + +val b = project.settings( + apiBaseSetting, + publishMavenStyle := false, + publishTo := Some(bResolver.value) +) + +val c = project.settings(apiBaseSetting) + +val d = project.dependsOn( c ).settings( + externalResolvers := Seq(aResolver.value, bResolver.value), + addDep("a"), + addDep("b"), + checkApiMappings := { + val actual = apiMappings.in(Compile,doc).value + println("Actual API Mappings: " + actual.mkString("\n\t", "\n\t", "")) + val expected = expectedMappings.value + println("Expected API Mappings: " + expected.mkString("\n\t", "\n\t", "")) + assert(actual == expected) + } +) diff --git a/sbt/src/sbt-test/actions/external-doc/test b/sbt/src/sbt-test/actions/external-doc/test new file mode 100644 index 000000000..3c65f869f --- /dev/null +++ b/sbt/src/sbt-test/actions/external-doc/test @@ -0,0 +1,11 @@ +# Project A publishes as a pom to test that the API URL is properly handled from poms +> a/publish + +# Project B publishes as ivy.xml to test that format +> b/publish + +# Project C is an internal dependency to test that unpublished internal dependencies +# have their URL properly set +# Project D uses all three projects as well as an unmanaged dependency +# Here, verify that the API mappings have been properly gathered +> d/checkApiMappings