From a1529659337f968161f6e6c5cc46386e2a827349 Mon Sep 17 00:00:00 2001 From: Mark Harrah Date: Thu, 10 Jan 2013 16:06:12 -0500 Subject: [PATCH] Option to automatically manage API documentation mappings Set autoAPIMappings := true to enable. Then, set apiURL to the base URL of the API documentation for a project. This will get stored in an extra attribute in the ivy.xml or as a property a pom.xml. When using managed dependencies that have set their apiURL, the -doc-external-doc setting for scaladoc will be automatically configured. Note that this option will only be available in Scala 2.10.1 and so enabling autoAPIMappings for earlier versions will result in an error from scaladoc. For unmanaged dependencies or dependencies without an automatic apiURL, add the (File,URL) mapping to apiMappings. The File is the classpath entry and the URL is the location of the API documentation. --- ivy/src/main/scala/sbt/CustomPomParser.scala | 8 ++- ivy/src/main/scala/sbt/IvyActions.scala | 2 +- ivy/src/main/scala/sbt/IvyInterface.scala | 9 ++- main/src/main/scala/sbt/APIMappings.scala | 33 +++++++++ main/src/main/scala/sbt/Defaults.scala | 25 +++++-- main/src/main/scala/sbt/Keys.scala | 4 ++ main/src/main/scala/sbt/Opts.scala | 13 ++-- .../sbt-test/actions/external-doc/build.sbt | 70 +++++++++++++++++++ sbt/src/sbt-test/actions/external-doc/test | 11 +++ 9 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 main/src/main/scala/sbt/APIMappings.scala create mode 100644 sbt/src/sbt-test/actions/external-doc/build.sbt create mode 100644 sbt/src/sbt-test/actions/external-doc/test 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