diff --git a/main/src/main/scala/sbt/Build.scala b/main/src/main/scala/sbt/Build.scala index 0501f9259..7bcb704ec 100644 --- a/main/src/main/scala/sbt/Build.scala +++ b/main/src/main/scala/sbt/Build.scala @@ -18,6 +18,7 @@ trait Build * If None, the root project is the first project in the build's root directory or just the first project if none are in the root directory.*/ def rootProject: Option[Project] = None } +// TODO 0.14.0: decide if Plugin should be deprecated in favor of AutoPlugin trait Plugin { @deprecated("Override projectSettings or buildSettings instead.", "0.12.0") diff --git a/main/src/main/scala/sbt/BuildStructure.scala b/main/src/main/scala/sbt/BuildStructure.scala index 9db86a90f..1fddbf2a0 100644 --- a/main/src/main/scala/sbt/BuildStructure.scala +++ b/main/src/main/scala/sbt/BuildStructure.scala @@ -30,23 +30,108 @@ final class StructureIndex( val keyIndex: KeyIndex, val aggregateKeyIndex: KeyIndex ) + +/** A resolved build unit. (`ResolvedBuildUnit` would be a better name to distinguish it from the loaded, but unresolved `BuildUnit`.) +* @param unit The loaded, but unresolved [[BuildUnit]] this was resolved from. +* @param defined The definitive map from project IDs to resolved projects. +* These projects have had [[Reference]]s resolved and [[AutoPlugin]]s evaluated. +* @param rootProjects The list of project IDs for the projects considered roots of this build. +* The first root project is used as the default in several situations where a project is not otherwise selected. +*/ final class LoadedBuildUnit(val unit: BuildUnit, val defined: Map[String, ResolvedProject], val rootProjects: Seq[String], val buildSettings: Seq[Setting[_]]) extends BuildUnitBase { assert(!rootProjects.isEmpty, "No root projects defined for build unit " + unit) + /** The project to use as the default when one is not otherwise selected. + * [[LocalRootProject]] resolves to this from within the same build.*/ val root = rootProjects.head + + /** The base directory of the build unit (not the build definition).*/ def localBase = unit.localBase + + /** The classpath to use when compiling against this build unit's publicly visible code. + * It includes build definition and plugin classes, but not classes for .sbt file statements and expressions. */ def classpath: Seq[File] = unit.definitions.target ++ unit.plugins.classpath + + /** The class loader to use for this build unit's publicly visible code. + * It includes build definition and plugin classes, but not classes for .sbt file statements and expressions. */ def loader = unit.definitions.loader + + /** The imports to use for .sbt files, `consoleProject` and other contexts that use code from the build definition. */ def imports = BuildUtil.getImports(unit) override def toString = unit.toString } +// TODO: figure out how to deprecate and drop buildNames +/** The built and loaded build definition, including loaded but unresolved [[Project]]s, for a build unit (for a single URI). +* +* @param base The base directory of the build definition, typically `/project/`. +* @param loader The ClassLoader containing all classes and plugins for the build definition project. +* Note that this does not include classes for .sbt files. +* @param builds The list of [[Build]]s for the build unit. +* In addition to auto-discovered [[Build]]s, this includes any auto-generated default [[Build]]s. +* @param projects The list of all [[Project]]s from all [[Build]]s. +* These projects have not yet been resolved, but they have had auto-plugins applied. +* In particular, each [[Project]]'s `autoPlugins` field is populated according to their configured `natures` +* and their `settings` and `configurations` updated as appropriate. +* @param buildNames No longer used and will be deprecated once feasible. +*/ final class LoadedDefinitions(val base: File, val target: Seq[File], val loader: ClassLoader, val builds: Seq[Build], val projects: Seq[Project], val buildNames: Seq[String]) -final class LoadedPlugins(val base: File, val pluginData: PluginData, val loader: ClassLoader, val plugins: Seq[Plugin], val pluginNames: Seq[String]) + +/** Auto-detected top-level modules (as in `object X`) of type `T` paired with their source names. */ +final class DetectedModules[T](val modules: Seq[(String, T)]) { + /** The source names of the modules. This is "X" in `object X`, as opposed to the implementation class name "X$". + * The names are returned in a stable order such that `names zip values` pairs a name with the actual module. */ + def names: Seq[String] = modules.map(_._1) + + /** The singleton value of the module. + * The values are returned in a stable order such that `names zip values` pairs a name with the actual module. */ + def values: Seq[T] = modules.map(_._2) +} + +/** Auto-discovered modules for the build definition project. These include modules defined in build definition sources +* as well as modules in binary dependencies. +* +* @param builds The [[Build]]s detected in the build definition. This does not include the default [[Build]] that sbt creates if none is defined. +*/ +final class DetectedPlugins(val plugins: DetectedModules[Plugin], val autoImports: DetectedModules[AutoImport], val autoPlugins: DetectedModules[AutoPlugin], val builds: DetectedModules[Build]) +{ + /** Sequence of import expressions for the build definition. This includes the names of the [[Plugin]], [[Build]], and [[AutoImport]] modules, but not the [[AutoPlugin]] modules. */ + lazy val imports: Seq[String] = BuildUtil.getImports(plugins.names ++ builds.names ++ autoImports.names) + + /** A function to select the right [[AutoPlugin]]s from [[autoPlugins]] given the defined [[Natures]] for a [[Project]]. */ + lazy val compileNatures: Natures => Seq[AutoPlugin] = Natures.compile(autoPlugins.values.toList) +} + +/** The built and loaded build definition project. +* @param base The base directory for the build definition project (not the base of the project itself). +* @param pluginData Evaluated tasks/settings from the build definition for later use. +* This is necessary because the build definition project is discarded. +* @param loader The class loader for the build definition project, notably excluding classes used for .sbt files. +* @param detected Auto-detected modules in the build definition. +*/ +final class LoadedPlugins(val base: File, val pluginData: PluginData, val loader: ClassLoader, val detected: DetectedPlugins) +{ + @deprecated("Use the primary constructor.", "0.13.2") + def this(base: File, pluginData: PluginData, loader: ClassLoader, plugins: Seq[Plugin], pluginNames: Seq[String]) = + this(base, pluginData, loader, + new DetectedPlugins(new DetectedModules(pluginNames zip plugins), new DetectedModules(Nil), new DetectedModules(Nil), new DetectedModules(Nil)) + ) + + @deprecated("Use detected.plugins.values.", "0.13.2") + val plugins: Seq[Plugin] = detected.plugins.values + @deprecated("Use detected.plugins.names.", "0.13.2") + val pluginNames: Seq[String] = detected.plugins.names + def fullClasspath: Seq[Attributed[File]] = pluginData.classpath def classpath = data(fullClasspath) + } +/** The loaded, but unresolved build unit. +* @param uri The uniquely identifying URI for the build. +* @param localBase The working location of the build on the filesystem. +* For local URIs, this is the same as `uri`, but for remote URIs, this is the local copy or workspace allocated for the build. +*/ final class BuildUnit(val uri: URI, val localBase: File, val definitions: LoadedDefinitions, val plugins: LoadedPlugins) { override def toString = if(uri.getScheme == "file") localBase.toString else (uri + " (locally: " + localBase +")") @@ -57,6 +142,8 @@ final class LoadedBuild(val root: URI, val units: Map[URI, LoadedBuildUnit]) BuildUtil.checkCycles(units) def allProjectRefs: Seq[(ProjectRef, ResolvedProject)] = for( (uri, unit) <- units.toSeq; (id, proj) <- unit.defined ) yield ProjectRef(uri, id) -> proj def extra(data: Settings[Scope])(keyIndex: KeyIndex): BuildUtil[ResolvedProject] = BuildUtil(root, units, keyIndex, data) + + private[sbt] def autos = GroupedAutoPlugins(units) } final class PartBuild(val root: URI, val units: Map[URI, PartBuildUnit]) sealed trait BuildUnitBase { def rootProjects: Seq[String]; def buildSettings: Seq[Setting[_]] } diff --git a/main/src/main/scala/sbt/BuildUtil.scala b/main/src/main/scala/sbt/BuildUtil.scala index df57581bd..dd963e05d 100644 --- a/main/src/main/scala/sbt/BuildUtil.scala +++ b/main/src/main/scala/sbt/BuildUtil.scala @@ -35,7 +35,7 @@ final class BuildUtil[Proj]( case _ => None } - val configurationsForAxis: Option[ResolvedReference] => Seq[String] = + val configurationsForAxis: Option[ResolvedReference] => Seq[String] = refOpt => configurations(projectForAxis(refOpt)).map(_.name) } object BuildUtil @@ -48,6 +48,20 @@ object BuildUtil new BuildUtil(keyIndex, data, root, Load getRootProject units, getp, configs, aggregates) } + def dependencies(units: Map[URI, LoadedBuildUnit]): BuildDependencies = + { + import collection.mutable.HashMap + val agg = new HashMap[ProjectRef, Seq[ProjectRef]] + val cp = new HashMap[ProjectRef, Seq[ClasspathDep[ProjectRef]]] + for(lbu <- units.values; rp <- lbu.defined.values) + { + val ref = ProjectRef(lbu.unit.uri, rp.id) + cp(ref) = rp.dependencies + agg(ref) = rp.aggregate + } + BuildDependencies(cp.toMap, agg.toMap) + } + def checkCycles(units: Map[URI, LoadedBuildUnit]) { def getRef(pref: ProjectRef) = units(pref.build).defined(pref.project) @@ -60,8 +74,14 @@ object BuildUtil } } def baseImports: Seq[String] = "import sbt._, Keys._" :: Nil - def getImports(unit: BuildUnit): Seq[String] = getImports(unit.plugins.pluginNames, unit.definitions.buildNames) - def getImports(pluginNames: Seq[String], buildNames: Seq[String]): Seq[String] = baseImports ++ importAllRoot(pluginNames ++ buildNames) + + def getImports(unit: BuildUnit): Seq[String] = unit.plugins.detected.imports + + @deprecated("Use getImports(Seq[String]).", "0.13.2") + def getImports(pluginNames: Seq[String], buildNames: Seq[String]): Seq[String] = getImports(pluginNames ++ buildNames) + + def getImports(names: Seq[String]): Seq[String] = baseImports ++ importAllRoot(names) + def importAll(values: Seq[String]): Seq[String] = if(values.isEmpty) Nil else values.map( _ + "._" ).mkString("import ", ", ", "") :: Nil def importAllRoot(values: Seq[String]): Seq[String] = importAll(values map rootedName) def rootedName(s: String): String = if(s contains '.') "_root_." + s else s diff --git a/main/src/main/scala/sbt/CommandStrings.scala b/main/src/main/scala/sbt/CommandStrings.scala index 9baf0e7d1..14cc6fee9 100644 --- a/main/src/main/scala/sbt/CommandStrings.scala +++ b/main/src/main/scala/sbt/CommandStrings.scala @@ -49,6 +49,11 @@ $ShowCommand Evaluates the specified task and display the value returned by the task.""" + val PluginsCommand = "plugins" + val PluginCommand = "plugin" + def pluginsBrief = "Lists currently available plugins." + def pluginsDetailed = pluginsBrief // TODO: expand + val LastCommand = "last" val LastGrepCommand = "last-grep" val ExportCommand = "export" diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 465578606..9657c3644 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -179,7 +179,7 @@ object Defaults extends BuildCommon unmanagedResources <<= collectFiles(unmanagedResourceDirectories, includeFilter in unmanagedResources, excludeFilter in unmanagedResources), watchSources in ConfigGlobal ++= unmanagedResources.value, resourceGenerators :== Nil, - resourceGenerators <+= (definedSbtPlugins, resourceManaged) map writePluginsDescriptor, + resourceGenerators <+= (discoveredSbtPlugins, resourceManaged) map PluginDiscovery.writeDescriptors, managedResources <<= generate(resourceGenerators), resources <<= Classpaths.concat(managedResources, unmanagedResources) ) @@ -233,6 +233,7 @@ object Defaults extends BuildCommon consoleQuick <<= consoleQuickTask, discoveredMainClasses <<= compile map discoverMainClasses storeAs discoveredMainClasses triggeredBy compile, definedSbtPlugins <<= discoverPlugins, + discoveredSbtPlugins <<= discoverSbtPluginNames, inTask(run)(runnerTask :: Nil).head, selectMainClass := mainClass.value orElse selectRunMain(discoveredMainClasses.value), mainClass in run := (selectMainClass in run).value, @@ -764,27 +765,21 @@ object Defaults extends BuildCommon def sbtPluginExtra(m: ModuleID, sbtV: String, scalaV: String): ModuleID = m.extra(CustomPomParser.SbtVersionKey -> sbtV, CustomPomParser.ScalaVersionKey -> scalaV).copy(crossVersion = CrossVersion.Disabled) + + @deprecated("Use PluginDiscovery.writeDescriptor.", "0.13.2") def writePluginsDescriptor(plugins: Set[String], dir: File): Seq[File] = - { - val descriptor: File = dir / "sbt" / "sbt.plugins" - if(plugins.isEmpty) - { - IO.delete(descriptor) - Nil - } - else - { - IO.writeLines(descriptor, plugins.toSeq.sorted) - descriptor :: Nil - } + PluginDiscovery.writeDescriptor(plugins.toSeq, dir, PluginDiscovery.Paths.Plugins).toList + + def discoverSbtPluginNames: Initialize[Task[PluginDiscovery.DiscoveredNames]] = Def.task { + if(sbtPlugin.value) PluginDiscovery.discoverSourceAll(compile.value) else PluginDiscovery.emptyDiscoveredNames } + + @deprecated("Use discoverSbtPluginNames.", "0.13.2") def discoverPlugins: Initialize[Task[Set[String]]] = (compile, sbtPlugin, streams) map { (analysis, isPlugin, s) => if(isPlugin) discoverSbtPlugins(analysis, s.log) else Set.empty } + + @deprecated("Use PluginDiscovery.sourceModuleNames[Plugin].", "0.13.2") def discoverSbtPlugins(analysis: inc.Analysis, log: Logger): Set[String] = - { - val pluginClass = classOf[Plugin].getName - val discovery = Discovery(Set(pluginClass), Set.empty)( Tests allDefs analysis ) - discovery collect { case (df, disc) if (disc.baseClasses contains pluginClass) && disc.isModule => df.name } toSet; - } + PluginDiscovery.sourceModuleNames(analysis, classOf[Plugin].getName).toSet def copyResourcesTask = (classDirectory, resources, resourceDirectories, streams) map { (target, resrcs, dirs, s) => @@ -1262,19 +1257,8 @@ object Classpaths if(useJars) Seq(pkgTask).join else psTask } - def constructBuildDependencies: Initialize[BuildDependencies] = - loadedBuild { lb => - import collection.mutable.HashMap - val agg = new HashMap[ProjectRef, Seq[ProjectRef]] - val cp = new HashMap[ProjectRef, Seq[ClasspathDep[ProjectRef]]] - for(lbu <- lb.units.values; rp <- lbu.defined.values) - { - val ref = ProjectRef(lbu.unit.uri, rp.id) - cp(ref) = rp.dependencies - agg(ref) = rp.aggregate - } - BuildDependencies(cp.toMap, agg.toMap) - } + def constructBuildDependencies: Initialize[BuildDependencies] = loadedBuild(lb => BuildUtil.dependencies(lb.units)) + def internalDependencies: Initialize[Task[Classpath]] = (thisProjectRef, classpathConfiguration, configuration, settingsData, buildDependencies) flatMap internalDependencies0 def unmanagedDependencies: Initialize[Task[Classpath]] = diff --git a/main/src/main/scala/sbt/GroupedAutoPlugins.scala b/main/src/main/scala/sbt/GroupedAutoPlugins.scala new file mode 100644 index 000000000..d020ad31e --- /dev/null +++ b/main/src/main/scala/sbt/GroupedAutoPlugins.scala @@ -0,0 +1,20 @@ +package sbt + + import Def.Setting + import java.net.URI + +private[sbt] final class GroupedAutoPlugins(val all: Seq[AutoPlugin], val byBuild: Map[URI, Seq[AutoPlugin]]) +{ + def globalSettings: Seq[Setting[_]] = all.flatMap(_.globalSettings) + def buildSettings(uri: URI): Seq[Setting[_]] = byBuild.getOrElse(uri, Nil).flatMap(_.buildSettings) +} + +private[sbt] object GroupedAutoPlugins +{ + private[sbt] def apply(units: Map[URI, LoadedBuildUnit]): GroupedAutoPlugins = + { + val byBuild: Map[URI, Seq[AutoPlugin]] = units.mapValues(unit => unit.defined.values.flatMap(_.autoPlugins).toSeq.distinct).toMap + val all: Seq[AutoPlugin] = byBuild.values.toSeq.flatten.distinct + new GroupedAutoPlugins(all, byBuild) + } +} \ No newline at end of file diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 17f64e0b3..ceb7813ed 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -131,6 +131,7 @@ object Keys val crossVersion = SettingKey[CrossVersion]("cross-version", "Configures handling of the Scala version when cross-building.", CSetting) val classpathOptions = SettingKey[ClasspathOptions]("classpath-options", "Configures handling of Scala classpaths.", DSetting) val definedSbtPlugins = TaskKey[Set[String]]("defined-sbt-plugins", "The set of names of Plugin implementations defined by this project.", CTask) + val discoveredSbtPlugins = TaskKey[PluginDiscovery.DiscoveredNames]("discovered-sbt-plugins", "The names of sbt plugin-related modules (modules that extend Build, Plugin, AutoImport, AutoPlugin) defined by this project.", CTask) val sbtPlugin = SettingKey[Boolean]("sbt-plugin", "If true, enables adding sbt as a dependency and auto-generation of the plugin descriptor file.", BMinusSetting) val printWarnings = TaskKey[Unit]("print-warnings", "Shows warnings from compilation, including ones that weren't printed initially.", BPlusTask) val fileInputOptions = SettingKey[Seq[String]]("file-input-options", "Options that take file input, which may invalidate the cache.", CSetting) @@ -348,7 +349,7 @@ object Keys // Experimental in sbt 0.13.2 to enable grabing semantic compile failures. private[sbt] val compilerReporter = TaskKey[Option[xsbti.Reporter]]("compilerReporter", "Experimental hook to listen (or send) compilation failure messages.", DTask) - + val triggeredBy = Def.triggeredBy val runBefore = Def.runBefore diff --git a/main/src/main/scala/sbt/Load.scala b/main/src/main/scala/sbt/Load.scala index 8b7f3465a..2a00e7329 100755 --- a/main/src/main/scala/sbt/Load.scala +++ b/main/src/main/scala/sbt/Load.scala @@ -6,19 +6,16 @@ package sbt import java.io.File import java.net.{URI,URL} import compiler.{Eval,EvalImports} - import xsbt.api.{Discovered,Discovery} - import xsbti.compile.CompileOrder import classpath.ClasspathUtilities import scala.annotation.tailrec import collection.mutable - import Compiler.{Compilers,Inputs} + import Compiler.Compilers import inc.{FileValueCache, Locate} import Project.{inScope,makeSettings} import Def.{isDummy, ScopedKey, ScopeLocal, Setting} import Keys.{appConfiguration, baseDirectory, configuration, fullResolvers, fullClasspath, pluginData, streams, thisProject, thisProjectRef, update} import Keys.{exportedProducts, loadedBuild, onLoadMessage, resolvedScoped, sbtPlugin, scalacOptions, taskDefinitionKey} import tools.nsc.reporters.ConsoleReporter - import Build.analyzed import Attributed.data import Scope.{GlobalScope, ThisScope} import Types.const @@ -180,7 +177,7 @@ object Load val keys = Index.allKeys(settings) val attributeKeys = Index.attributeKeys(data) ++ keys.map(_.key) val scopedKeys = keys ++ data.allKeys( (s,k) => ScopedKey(s,k)) - val projectsMap = projects.mapValues(_.defined.keySet) + val projectsMap = projects.mapValues(_.defined.keySet).toMap val keyIndex = KeyIndex(scopedKeys, projectsMap) val aggIndex = KeyIndex.aggregate(scopedKeys, extra(keyIndex), projectsMap) new sbt.StructureIndex(Index.stringToKeyMap(attributeKeys), Index.taskToKeyMap(data), Index.triggers(data), keyIndex, aggIndex) @@ -201,10 +198,10 @@ object Load { ((loadedBuild in GlobalScope :== loaded) +: transformProjectOnly(loaded.root, rootProject, injectSettings.global)) ++ - inScope(GlobalScope)( pluginGlobalSettings(loaded) ) ++ + inScope(GlobalScope)( pluginGlobalSettings(loaded) ++ loaded.autos.globalSettings ) ++ loaded.units.toSeq.flatMap { case (uri, build) => - val plugins = build.unit.plugins.plugins - val pluginBuildSettings = plugins.flatMap(_.buildSettings) + val plugins = build.unit.plugins.detected.plugins.values + val pluginBuildSettings = plugins.flatMap(_.buildSettings) ++ loaded.autos.buildSettings(uri) val pluginNotThis = plugins.flatMap(_.settings) filterNot isProjectThis val projectSettings = build.defined flatMap { case (id, project) => val ref = ProjectRef(uri, id) @@ -220,9 +217,10 @@ object Load buildSettings ++ projectSettings } } + @deprecated("Does not account for AutoPlugins and will be made private.", "0.13.2") def pluginGlobalSettings(loaded: sbt.LoadedBuild): Seq[Setting[_]] = loaded.units.toSeq flatMap { case (_, build) => - build.unit.plugins.plugins flatMap { _.globalSettings } + build.unit.plugins.detected.plugins.values flatMap { _.globalSettings } } @deprecated("No longer used.", "0.13.0") @@ -368,10 +366,11 @@ object Load def resolveProjects(loaded: sbt.PartBuild): sbt.LoadedBuild = { val rootProject = getRootProject(loaded.units) - new sbt.LoadedBuild(loaded.root, loaded.units map { case (uri, unit) => + val units = loaded.units map { case (uri, unit) => IO.assertAbsolute(uri) (uri, resolveProjects(uri, unit, rootProject)) - }) + } + new sbt.LoadedBuild(loaded.root, units) } def resolveProjects(uri: URI, unit: sbt.PartBuildUnit, rootProject: URI => String): sbt.LoadedBuildUnit = { @@ -399,10 +398,10 @@ object Load def getBuild[T](map: Map[URI, T], uri: URI): T = map.getOrElse(uri, noBuild(uri)) - def emptyBuild(uri: URI) = sys.error("No root project defined for build unit '" + uri + "'") - def noBuild(uri: URI) = sys.error("Build unit '" + uri + "' not defined.") - def noProject(uri: URI, id: String) = sys.error("No project '" + id + "' defined in '" + uri + "'.") - def noConfiguration(uri: URI, id: String, conf: String) = sys.error("No configuration '" + conf + "' defined in project '" + id + "' in '" + uri +"'") + def emptyBuild(uri: URI) = sys.error(s"No root project defined for build unit '$uri'") + def noBuild(uri: URI) = sys.error(s"Build unit '$uri' not defined.") + def noProject(uri: URI, id: String) = sys.error(s"No project '$id' defined in '$uri'.") + def noConfiguration(uri: URI, id: String, conf: String) = sys.error(s"No configuration '$conf' defined in project '$id' in '$uri'") def loadUnit(uri: URI, localBase: File, s: State, config: sbt.LoadBuildConfiguration): sbt.BuildUnit = { @@ -410,15 +409,13 @@ object Load val defDir = projectStandard(normBase) val plugs = plugins(defDir, s, config.copy(pluginManagement = config.pluginManagement.forPlugin)) - val defNames = analyzed(plugs.fullClasspath) flatMap findDefinitions - val defsScala = if(defNames.isEmpty) Nil else loadDefinitions(plugs.loader, defNames) - val imports = BuildUtil.getImports(plugs.pluginNames, defNames) + val defsScala = plugs.detected.builds.values lazy val eval = mkEval(plugs.classpath, defDir, plugs.pluginData.scalacOptions) val initialProjects = defsScala.flatMap(b => projectsFromBuild(b, normBase)) val memoSettings = new mutable.HashMap[File, LoadedSbtFile] - def loadProjects(ps: Seq[Project]) = loadTransitive(ps, normBase, imports, plugs, () => eval, config.injectSettings, Nil, memoSettings) + def loadProjects(ps: Seq[Project]) = loadTransitive(ps, normBase, plugs, () => eval, config.injectSettings, Nil, memoSettings) val loadedProjectsRaw = loadProjects(initialProjects) val hasRoot = loadedProjectsRaw.exists(_.base == normBase) || defsScala.exists(_.rootProject.isDefined) val (loadedProjects, defaultBuildIfNone) = @@ -434,7 +431,7 @@ object Load } val defs = if(defsScala.isEmpty) defaultBuildIfNone :: Nil else defsScala - val loadedDefs = new sbt.LoadedDefinitions(defDir, Nil, plugs.loader, defs, loadedProjects, defNames) + val loadedDefs = new sbt.LoadedDefinitions(defDir, Nil, plugs.loader, defs, loadedProjects, plugs.detected.builds.names) new sbt.BuildUnit(uri, normBase, loadedDefs, plugs) } @@ -460,16 +457,22 @@ object Load private[this] def projectsFromBuild(b: Build, base: File): Seq[Project] = b.projectDefinitions(base).map(resolveBase(base)) - private[this] def loadTransitive(newProjects: Seq[Project], buildBase: File, imports: Seq[String], plugins: sbt.LoadedPlugins, eval: () => Eval, injectSettings: InjectSettings, acc: Seq[Project], memoSettings: mutable.Map[File, LoadedSbtFile]): Seq[Project] = + private[this] def loadTransitive(newProjects: Seq[Project], buildBase: File, plugins: sbt.LoadedPlugins, eval: () => Eval, injectSettings: InjectSettings, acc: Seq[Project], memoSettings: mutable.Map[File, LoadedSbtFile]): Seq[Project] = { - def loadSbtFiles(auto: AddSettings, base: File): LoadedSbtFile = - loadSettings(auto, base, imports, plugins, eval, injectSettings, memoSettings) + def loadSbtFiles(auto: AddSettings, base: File, autoPlugins: Seq[AutoPlugin]): LoadedSbtFile = + loadSettings(auto, base, plugins, eval, injectSettings, memoSettings, autoPlugins) def loadForProjects = newProjects map { project => - val loadedSbtFiles = loadSbtFiles(project.auto, project.base) - val transformed = project.copy(settings = (project.settings: Seq[Setting[_]]) ++ loadedSbtFiles.settings) + val autoPlugins = + try plugins.detected.compileNatures(project.natures) + catch { case e: AutoPluginException => throw translateAutoPluginException(e, project) } + val autoConfigs = autoPlugins.flatMap(_.projectConfigurations) + val loadedSbtFiles = loadSbtFiles(project.auto, project.base, autoPlugins) + val newSettings = (project.settings: Seq[Setting[_]]) ++ loadedSbtFiles.settings + // add the automatically selected settings, record the selected AutoPlugins, and register the automatically selected configurations + val transformed = project.copy(settings = newSettings).setAutoPlugins(autoPlugins).overrideConfigs(autoConfigs : _*) (transformed, loadedSbtFiles.projects) } - def defaultLoad = loadSbtFiles(AddSettings.defaultSbtFiles, buildBase).projects + def defaultLoad = loadSbtFiles(AddSettings.defaultSbtFiles, buildBase, Nil).projects val (nextProjects, loadedProjects) = if(newProjects.isEmpty) // load the .sbt files in the root directory to look for Projects (defaultLoad, acc) @@ -481,10 +484,12 @@ object Load if(nextProjects.isEmpty) loadedProjects else - loadTransitive(nextProjects, buildBase, imports, plugins, eval, injectSettings, loadedProjects, memoSettings) + loadTransitive(nextProjects, buildBase, plugins, eval, injectSettings, loadedProjects, memoSettings) } + private[this] def translateAutoPluginException(e: AutoPluginException, project: Project): AutoPluginException = + e.withPrefix(s"Error determining plugins for project '${project.id}' in ${project.base}:\n") - private[this] def loadSettings(auto: AddSettings, projectBase: File, buildImports: Seq[String], loadedPlugins: sbt.LoadedPlugins, eval: ()=>Eval, injectSettings: InjectSettings, memoSettings: mutable.Map[File, LoadedSbtFile]): LoadedSbtFile = + private[this] def loadSettings(auto: AddSettings, projectBase: File, loadedPlugins: sbt.LoadedPlugins, eval: ()=>Eval, injectSettings: InjectSettings, memoSettings: mutable.Map[File, LoadedSbtFile], autoPlugins: Seq[AutoPlugin]): LoadedSbtFile = { lazy val defaultSbtFiles = configurationSources(projectBase) def settings(ss: Seq[Setting[_]]) = new LoadedSbtFile(ss, Nil, Nil) @@ -499,14 +504,20 @@ object Load lf } def loadSettingsFile(src: File): LoadedSbtFile = - EvaluateConfigurations.evaluateSbtFile(eval(), src, IO.readLines(src), buildImports, 0)(loader) + EvaluateConfigurations.evaluateSbtFile(eval(), src, IO.readLines(src), loadedPlugins.detected.imports, 0)(loader) import AddSettings.{User,SbtFiles,DefaultSbtFiles,Plugins,Sequence} + def pluginSettings(f: Plugins) = { + val included = loadedPlugins.detected.plugins.values.filter(f.include) // don't apply the filter to AutoPlugins, only Plugins + val oldStyle = included.flatMap(p => p.settings.filter(isProjectThis) ++ p.projectSettings) + val autoStyle = autoPlugins.flatMap(_.projectSettings) + oldStyle ++ autoStyle + } def expand(auto: AddSettings): LoadedSbtFile = auto match { case User => settings(injectSettings.projectLoaded(loader)) case sf: SbtFiles => loadSettings( sf.files.map(f => IO.resolve(projectBase, f))) case sf: DefaultSbtFiles => loadSettings( defaultSbtFiles.filter(sf.include)) - case f: Plugins => settings(loadedPlugins.plugins.filter(f.include).flatMap(p => p.settings.filter(isProjectThis) ++ p.projectSettings)) + case p: Plugins => settings(pluginSettings(p)) case q: Sequence => (LoadedSbtFile.empty /: q.sequence) { (b,add) => b.merge( expand(add) ) } } expand(auto) @@ -599,67 +610,46 @@ object Load config.evalPluginDef(pluginDef, pluginState) } + @deprecated("Use ModuleUtilities.getCheckedObjects[Build].", "0.13.2") def loadDefinitions(loader: ClassLoader, defs: Seq[String]): Seq[Build] = defs map { definition => loadDefinition(loader, definition) } + + @deprecated("Use ModuleUtilities.getCheckedObject[Build].", "0.13.2") def loadDefinition(loader: ClassLoader, definition: String): Build = ModuleUtilities.getObject(definition, loader).asInstanceOf[Build] def loadPlugins(dir: File, data: PluginData, loader: ClassLoader): sbt.LoadedPlugins = - { - val (pluginNames, plugins) = if(data.classpath.isEmpty) (Nil, Nil) else { - val names = getPluginNames(data.classpath, loader) - val loaded = - try loadPlugins(loader, names) - catch { - case e: ExceptionInInitializerError => - val cause = e.getCause - if(cause eq null) throw e else throw cause - case e: LinkageError => incompatiblePlugins(data, e) - } - (names, loaded) - } - new sbt.LoadedPlugins(dir, data, loader, plugins, pluginNames) - } - private[this] def incompatiblePlugins(data: PluginData, t: LinkageError): Nothing = - { - val evicted = data.report.toList.flatMap(_.configurations.flatMap(_.evicted)) - val evictedModules = evicted map { id => (id.organization, id.name) } distinct ; - val evictedStrings = evictedModules map { case (o,n) => o + ":" + n } - val msgBase = "Binary incompatibility in plugins detected." - val msgExtra = if(evictedStrings.isEmpty) "" else "\nNote that conflicts were resolved for some dependencies:\n\t" + evictedStrings.mkString("\n\t") - throw new IncompatiblePluginsException(msgBase + msgExtra, t) - } + new sbt.LoadedPlugins(dir, data, loader, PluginDiscovery.discoverAll(data, loader)) + + @deprecated("Replaced by the more general PluginDiscovery.binarySourceModuleNames and will be made private.", "0.13.2") def getPluginNames(classpath: Seq[Attributed[File]], loader: ClassLoader): Seq[String] = - ( binaryPlugins(data(classpath), loader) ++ (analyzed(classpath) flatMap findPlugins) ).distinct + PluginDiscovery.binarySourceModuleNames(classpath, loader, PluginDiscovery.Paths.Plugins, classOf[Plugin].getName) + @deprecated("Use PluginDiscovery.binaryModuleNames.", "0.13.2") def binaryPlugins(classpath: Seq[File], loader: ClassLoader): Seq[String] = - { - import collection.JavaConversions._ - loader.getResources("sbt/sbt.plugins").toSeq.filter(onClasspath(classpath)) flatMap { u => - IO.readLinesURL(u).map( _.trim).filter(!_.isEmpty) - } - } + PluginDiscovery.binaryModuleNames(classpath, loader, PluginDiscovery.Paths.Plugins) + + @deprecated("Use PluginDiscovery.onClasspath", "0.13.2") def onClasspath(classpath: Seq[File])(url: URL): Boolean = - IO.urlAsFile(url) exists (classpath.contains _) + PluginDiscovery.onClasspath(classpath)(url) + @deprecated("Use ModuleUtilities.getCheckedObjects[Plugin].", "0.13.2") def loadPlugins(loader: ClassLoader, pluginNames: Seq[String]): Seq[Plugin] = - pluginNames.map(pluginName => loadPlugin(pluginName, loader)) + ModuleUtilities.getCheckedObjects[Plugin](pluginNames, loader).map(_._2) + @deprecated("Use ModuleUtilities.getCheckedObject[Plugin].", "0.13.2") def loadPlugin(pluginName: String, loader: ClassLoader): Plugin = - ModuleUtilities.getObject(pluginName, loader).asInstanceOf[Plugin] + ModuleUtilities.getCheckedObject[Plugin](pluginName, loader) + @deprecated("No longer used.", "0.13.2") def findPlugins(analysis: inc.Analysis): Seq[String] = discover(analysis, "sbt.Plugin") + + @deprecated("No longer used.", "0.13.2") def findDefinitions(analysis: inc.Analysis): Seq[String] = discover(analysis, "sbt.Build") + + @deprecated("Use PluginDiscovery.sourceModuleNames", "0.13.2") def discover(analysis: inc.Analysis, subclasses: String*): Seq[String] = - { - val subclassSet = subclasses.toSet - val ds = Discovery(subclassSet, Set.empty)(Tests.allDefs(analysis)) - ds.flatMap { - case (definition, Discovered(subs,_,_,true)) => - if((subs & subclassSet).isEmpty) Nil else definition.name :: Nil - case _ => Nil - } - } + PluginDiscovery.sourceModuleNames(analysis, subclasses : _*) def initialSession(structure: sbt.BuildStructure, rootEval: () => Eval, s: State): SessionSettings = { val session = s get Keys.sessionSettings diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 295ffca33..ad5291ec2 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -89,7 +89,7 @@ object BuiltinCommands def ScriptCommands: Seq[Command] = Seq(ignore, exit, Script.command, setLogLevel, early, act, nop) def DefaultCommands: Seq[Command] = Seq(ignore, help, completionsCommand, about, tasks, settingsCommand, loadProject, projects, project, reboot, read, history, set, sessionCommand, inspect, loadProjectImpl, loadFailed, Cross.crossBuild, Cross.switchVersion, - setOnFailure, clearOnFailure, stashOnFailure, popOnFailure, setLogLevel, + setOnFailure, clearOnFailure, stashOnFailure, popOnFailure, setLogLevel, plugin, plugins, ifLast, multi, shell, continuous, eval, alias, append, last, lastGrep, export, boot, nop, call, exit, early, initialize, act) ++ compatCommands def DefaultBootCommands: Seq[String] = LoadProject :: (IfLast + " " + Shell) :: Nil @@ -125,7 +125,8 @@ object BuiltinCommands def aboutPlugins(e: Extracted): String = { - val allPluginNames = e.structure.units.values.flatMap(_.unit.plugins.pluginNames).toSeq.distinct + def list(b: BuildUnit) = b.plugins.detected.autoPlugins.values.map(_.label) ++ b.plugins.detected.plugins.names + val allPluginNames = e.structure.units.values.flatMap(u => list(u.unit)).toSeq.distinct if(allPluginNames.isEmpty) "" else allPluginNames.mkString("Available Plugins: ", ", ", "") } def aboutScala(s: State, e: Extracted): String = @@ -374,6 +375,20 @@ object BuiltinCommands Help.detailOnly(taskDetail(allTaskAndSettingKeys(s))) else Help.empty + def plugins = Command.command(PluginsCommand, pluginsBrief, pluginsDetailed) { s => + val helpString = NaturesDebug.helpAll(s) + System.out.println(helpString) + s + } + val pluginParser: State => Parser[AutoPlugin] = s => { + val autoPlugins: Map[String, AutoPlugin] = NaturesDebug.autoPluginMap(s) + token(Space) ~> Act.knownIDParser(autoPlugins, "plugin") + } + def plugin = Command(PluginCommand)(pluginParser) { (s, plugin) => + val helpString = NaturesDebug.help(plugin, s) + System.out.println(helpString) + s + } def projects = Command(ProjectsCommand, (ProjectsCommand, projectsBrief), projectsDetailed )(s => projectsParser(s).?) { case (s, Some(modifyBuilds)) => transformExtraBuilds(s, modifyBuilds) diff --git a/main/src/main/scala/sbt/Natures.scala b/main/src/main/scala/sbt/Natures.scala new file mode 100644 index 000000000..06b0a0e2f --- /dev/null +++ b/main/src/main/scala/sbt/Natures.scala @@ -0,0 +1,207 @@ +package sbt +/* +TODO: +- index all available AutoPlugins to get the tasks that will be added +- error message when a task doesn't exist that it would be provided by plugin x, enabled by natures y,z, blocked by a, b +*/ + + import logic.{Atom, Clause, Clauses, Formula, Literal, Logic, Negated} + import Logic.{CyclicNegation, InitialContradictions, InitialOverlap, LogicException} + import Def.Setting + import Natures._ + +/** Marks a top-level object so that sbt will wildcard import it for .sbt files, `consoleProject`, and `set`. */ +trait AutoImport + +/** +An AutoPlugin defines a group of settings and the conditions where the settings are automatically added to a build (called "activation"). +The `select` method defines the conditions and a method like `projectSettings` defines the settings to add. + +Steps for plugin authors: +1. Determine the [[Nature]]s that, when present (or absent), activate the AutoPlugin. +2. Determine the settings/configurations to automatically inject when activated. + +For example, the following will automatically add the settings in `projectSettings` + to a project that has both the `Web` and `Javascript` natures enabled. + + object MyPlugin extends AutoPlugin { + def select = Web && Javascript + override def projectSettings = Seq(...) + } + +Steps for users: +1. Add dependencies on plugins as usual with addSbtPlugin +2. Add Natures to Projects, which will automatically select the plugin settings to add for those Projects. +3. Exclude plugins, if desired. + +For example, given natures Web and Javascript (perhaps provided by plugins added with addSbtPlugin), + + .natures( Web && Javascript ) + +will activate `MyPlugin` defined above and have its settings automatically added. If the user instead defines + + .natures( Web && Javascript && !MyPlugin) + +then the `MyPlugin` settings (and anything that activates only when `MyPlugin` is activated) will not be added. +*/ +abstract class AutoPlugin extends Natures.Basic +{ + /** This AutoPlugin will be activated for a project when the [[Natures]] matcher returned by this method matches that project's natures + * AND the user does not explicitly exclude the Nature returned by `provides`. + * + * For example, if this method returns `Web && Javascript`, this plugin instance will only be added + * if the `Web` and `Javascript` natures are enabled. */ + def select: Natures + + val label: String = getClass.getName.stripSuffix("$") + + /** The [[Configuration]]s to add to each project that activates this AutoPlugin.*/ + def projectConfigurations: Seq[Configuration] = Nil + + /** The [[Setting]]s to add in the scope of each project that activates this AutoPlugin. */ + def projectSettings: Seq[Setting[_]] = Nil + + /** The [[Setting]]s to add to the build scope for each project that activates this AutoPlugin. + * The settings returned here are guaranteed to be added to a given build scope only once + * regardless of how many projects for that build activate this AutoPlugin. */ + def buildSettings: Seq[Setting[_]] = Nil + + /** The [[Setting]]s to add to the global scope exactly once if any project activates this AutoPlugin. */ + def globalSettings: Seq[Setting[_]] = Nil + + // TODO?: def commands: Seq[Command] + + def unary_! : Exclude = Exclude(this) +} + +/** An error that occurs when auto-plugins aren't configured properly. +* It translates the error from the underlying logic system to be targeted at end users. */ +final class AutoPluginException private(val message: String, val origin: Option[LogicException]) extends RuntimeException(message) +{ + /** Prepends `p` to the error message derived from `origin`. */ + def withPrefix(p: String) = new AutoPluginException(p + message, origin) +} +object AutoPluginException +{ + def apply(msg: String): AutoPluginException = new AutoPluginException(msg, None) + def apply(origin: LogicException): AutoPluginException = new AutoPluginException(Natures.translateMessage(origin), Some(origin)) +} + +/** An expression that matches `Nature`s. */ +sealed trait Natures { + def && (o: Basic): Natures +} + +/** Represents a feature or conceptual group of settings. +* `label` is the unique ID for this nature. */ +final case class Nature(label: String) extends Basic { + /** Constructs a Natures matcher that excludes this Nature. */ + override def toString = label +} + +object Natures +{ + /** Given the available auto plugins `defined`, returns a function that selects [[AutoPlugin]]s for the provided [[Nature]]s. + * The [[AutoPlugin]]s are topologically sorted so that a selected [[AutoPlugin]] comes before its selecting [[AutoPlugin]].*/ + def compile(defined: List[AutoPlugin]): Natures => Seq[AutoPlugin] = + if(defined.isEmpty) + Types.const(Nil) + else + { + val byAtom = defined.map(x => (Atom(x.label), x)) + val byAtomMap = byAtom.toMap + if(byAtom.size != byAtomMap.size) duplicateProvidesError(byAtom) + val clauses = Clauses( defined.map(d => asClause(d)) ) + requestedNatures => + Logic.reduce(clauses, flattenConvert(requestedNatures).toSet) match { + case Left(problem) => throw AutoPluginException(problem) + case Right(results) => + // results includes the originally requested (positive) atoms, + // which won't have a corresponding AutoPlugin to map back to + results.ordered.flatMap(a => byAtomMap.get(a).toList) + } + } + + private[sbt] def translateMessage(e: LogicException) = e match { + case ic: InitialContradictions => s"Contradiction in selected natures. These natures were both included and excluded: ${literalsString(ic.literals.toSeq)}" + case io: InitialOverlap => s"Cannot directly enable plugins. Plugins are enabled when their required natures are satisifed. The directly selected plugins were: ${literalsString(io.literals.toSeq)}" + case cn: CyclicNegation => s"Cycles in plugin requirements cannot involve excludes. The problematic cycle is: ${literalsString(cn.cycle)}" + } + private[this] def literalsString(lits: Seq[Literal]): String = + lits map { case Atom(l) => l; case Negated(Atom(l)) => l } mkString(", ") + + private[this] def duplicateProvidesError(byAtom: Seq[(Atom, AutoPlugin)]) { + val dupsByAtom = byAtom.groupBy(_._1).mapValues(_.map(_._2)) + val dupStrings = for( (atom, dups) <- dupsByAtom if dups.size > 1 ) yield + s"${atom.label} by ${dups.mkString(", ")}" + val (ns, nl) = if(dupStrings.size > 1) ("s", "\n\t") else ("", " ") + val message = s"Nature$ns provided by multiple AutoPlugins:$nl${dupStrings.mkString(nl)}" + throw AutoPluginException(message) + } + + /** [[Natures]] instance that doesn't require any [[Nature]]s. */ + def empty: Natures = Empty + private[sbt] final object Empty extends Natures { + def &&(o: Basic): Natures = o + override def toString = "" + } + + /** An included or excluded Nature. TODO: better name than Basic. */ + sealed abstract class Basic extends Natures { + def &&(o: Basic): Natures = And(this :: o :: Nil) + } + private[sbt] final case class Exclude(n: AutoPlugin) extends Basic { + override def toString = s"!$n" + } + private[sbt] final case class And(natures: List[Basic]) extends Natures { + def &&(o: Basic): Natures = And(o :: natures) + override def toString = natures.mkString(", ") + } + private[sbt] def and(a: Natures, b: Natures) = b match { + case Empty => a + case And(ns) => (a /: ns)(_ && _) + case b: Basic => a && b + } + private[sbt] def remove(a: Natures, del: Set[Basic]): Natures = a match { + case b: Basic => if(del(b)) Empty else b + case Empty => Empty + case And(ns) => + val removed = ns.filterNot(del) + if(removed.isEmpty) Empty else And(removed) + } + + /** Defines a clause for `ap` such that the [[Nature]] provided by `ap` is the head and the selector for `ap` is the body. */ + private[sbt] def asClause(ap: AutoPlugin): Clause = + Clause( convert(ap.select), Set(Atom(ap.label)) ) + + private[this] def flattenConvert(n: Natures): Seq[Literal] = n match { + case And(ns) => convertAll(ns) + case b: Basic => convertBasic(b) :: Nil + case Empty => Nil + } + private[sbt] def flatten(n: Natures): Seq[Basic] = n match { + case And(ns) => ns + case b: Basic => b :: Nil + case Empty => Nil + } + + private[this] def convert(n: Natures): Formula = n match { + case And(ns) => convertAll(ns).reduce[Formula](_ && _) + case b: Basic => convertBasic(b) + case Empty => Formula.True + } + private[this] def convertBasic(b: Basic): Literal = b match { + case Exclude(n) => !convertBasic(n) + case Nature(s) => Atom(s) + case a: AutoPlugin => Atom(a.label) + } + private[this] def convertAll(ns: Seq[Basic]): Seq[Literal] = ns map convertBasic + + /** True if the select clause `n` is satisifed by `model`. */ + def satisfied(n: Natures, model: Set[AutoPlugin], natures: Set[Nature]): Boolean = + flatten(n) forall { + case Exclude(a) => !model(a) + case n: Nature => natures(n) + case ap: AutoPlugin => model(ap) + } +} \ No newline at end of file diff --git a/main/src/main/scala/sbt/NaturesDebug.scala b/main/src/main/scala/sbt/NaturesDebug.scala new file mode 100644 index 000000000..d0e27a9dd --- /dev/null +++ b/main/src/main/scala/sbt/NaturesDebug.scala @@ -0,0 +1,386 @@ +package sbt + + import Def.Setting + import Natures._ + import NaturesDebug._ + import java.net.URI + +private[sbt] class NaturesDebug(val available: List[AutoPlugin], val nameToKey: Map[String, AttributeKey[_]], val provided: Relation[AutoPlugin, AttributeKey[_]]) +{ + /** The set of [[AutoPlugin]]s that might define a key named `keyName`. + * Because plugins can define keys in different scopes, this should only be used as a guideline. */ + def providers(keyName: String): Set[AutoPlugin] = nameToKey.get(keyName) match { + case None => Set.empty + case Some(key) => provided.reverse(key) + } + /** Describes alternative approaches for defining key [[keyName]] in [[context]].*/ + def toEnable(keyName: String, context: Context): List[PluginEnable] = + providers(keyName).toList.map(plugin => pluginEnable(context, plugin)) + + /** Provides text to suggest how [[notFoundKey]] can be defined in [[context]]. */ + def debug(notFoundKey: String, context: Context): String = + { + val (activated, deactivated) = Util.separate(toEnable(notFoundKey, context)) { + case pa: PluginActivated => Left(pa) + case pd: EnableDeactivated => Right(pd) + } + val activePrefix = if(activated.nonEmpty) s"Some already activated plugins define $notFoundKey: ${activated.mkString(", ")}\n" else "" + activePrefix + debugDeactivated(notFoundKey, deactivated) + } + private[this] def debugDeactivated(notFoundKey: String, deactivated: Seq[EnableDeactivated]): String = + { + val (impossible, possible) = Util.separate(deactivated) { + case pi: PluginImpossible => Left(pi) + case pr: PluginRequirements => Right(pr) + } + if(possible.nonEmpty) { + val explained = possible.map(explainPluginEnable) + val possibleString = + if(explained.size > 1) explained.zipWithIndex.map{case (s,i) => s"$i. $s"}.mkString("Multiple plugins are available that can provide $notFoundKey:\n", "\n", "") + else s"$notFoundKey is provided by an available (but not activated) plugin:\n${explained.mkString}" + def impossiblePlugins = impossible.map(_.plugin.label).mkString(", ") + val imPostfix = if(impossible.isEmpty) "" else s"\n\nThere are other available plugins that provide $notFoundKey, but they are impossible to add: $impossiblePlugins" + possibleString + imPostfix + } + else if(impossible.isEmpty) + s"No available plugin provides key $notFoundKey." + else { + val explanations = impossible.map(explainPluginEnable) + explanations.mkString(s"Plugins are available that could provide $notFoundKey, but they are impossible to add:\n\t", "\n\t", "") + } + } + + /** Text that suggests how to activate [[plugin]] in [[context]] if possible and if it is not already activated.*/ + def help(plugin: AutoPlugin, context: Context): String = + if(context.enabled.contains(plugin)) + activatedHelp(plugin) + else + deactivatedHelp(plugin, context) + private def activatedHelp(plugin: AutoPlugin): String = + { + val prefix = s"${plugin.label} is activated." + val keys = provided.forward(plugin) + val keysString = if(keys.isEmpty) "" else s"\nIt may affect these keys: ${multi(keys.toList.map(_.label))}" + val configs = plugin.projectConfigurations + val confsString = if(configs.isEmpty) "" else s"\nIt defines these configurations: ${multi(configs.map(_.name))}" + prefix + keysString + confsString + } + private def deactivatedHelp(plugin: AutoPlugin, context: Context): String = + { + val prefix = s"${plugin.label} is NOT activated." + val keys = provided.forward(plugin) + val keysString = if(keys.isEmpty) "" else s"\nActivating it may affect these keys: ${multi(keys.toList.map(_.label))}" + val configs = plugin.projectConfigurations + val confsString = if(configs.isEmpty) "" else s"\nActivating it will define these configurations: ${multi(configs.map(_.name))}" + val toActivate = explainPluginEnable(pluginEnable(context, plugin)) + s"$prefix$keysString$confsString\n$toActivate" + } + + private[this] def multi(strs: Seq[String]): String = strs.mkString(if(strs.size > 4) "\n\t" else ", ") +} + +private[sbt] object NaturesDebug +{ + def helpAll(s: State): String = + if(Project.isProjectLoaded(s)) + { + val extracted = Project.extract(s) + import extracted._ + def helpBuild(uri: URI, build: LoadedBuildUnit): String = + { + val pluginStrings = for(plugin <- availableAutoPlugins(build)) yield { + val activatedIn = build.defined.values.toList.filter(_.autoPlugins.contains(plugin)).map(_.id) + val actString = if(activatedIn.nonEmpty) activatedIn.mkString(": enabled in ", ", ", "") else "" // TODO: deal with large builds + s"\n\t${plugin.label}$actString" + } + s"In $uri${pluginStrings.mkString}" + } + val buildStrings = for((uri, build) <- structure.units) yield helpBuild(uri, build) + buildStrings.mkString("\n") + } + else + "No project is currently loaded." + + def autoPluginMap(s: State): Map[String, AutoPlugin] = + { + val extracted = Project.extract(s) + import extracted._ + structure.units.values.toList.flatMap(availableAutoPlugins).map(plugin => (plugin.label, plugin)).toMap + } + private[this] def availableAutoPlugins(build: LoadedBuildUnit): Seq[AutoPlugin] = + build.unit.plugins.detected.autoPlugins.values + + def help(plugin: AutoPlugin, s: State): String = + { + val extracted = Project.extract(s) + import extracted._ + def definesPlugin(p: ResolvedProject): Boolean = p.autoPlugins.contains(plugin) + def projectForRef(ref: ProjectRef): ResolvedProject = get(Keys.thisProject in ref) + val perBuild: Map[URI, Set[AutoPlugin]] = structure.units.mapValues(unit => availableAutoPlugins(unit).toSet) + val pluginsThisBuild = perBuild.getOrElse(currentRef.build, Set.empty).toList + lazy val context = Context(currentProject.natures, currentProject.autoPlugins, Natures.compile(pluginsThisBuild), pluginsThisBuild) + lazy val debug = NaturesDebug(context.available) + if(!pluginsThisBuild.contains(plugin)) { + val availableInBuilds: List[URI] = perBuild.toList.filter(_._2(plugin)).map(_._1) + s"Plugin ${plugin.label} is only available in builds:\n\t${availableInBuilds.mkString("\n\t")}\nSwitch to a project in one of those builds using `project` and rerun this command for more information." + } else if(definesPlugin(currentProject)) + debug.activatedHelp(plugin) + else { + val thisAggregated = BuildUtil.dependencies(structure.units).aggregateTransitive.getOrElse(currentRef, Nil) + val definedInAggregated = thisAggregated.filter(ref => definesPlugin(projectForRef(ref))) + if(definedInAggregated.nonEmpty) { + val projectNames = definedInAggregated.map(_.project) // TODO: usually in this build, but could technically require the build to be qualified + s"Plugin ${plugin.label} is not activated on this project, but this project aggregates projects where it is activated:\n\t${projectNames.mkString("\n\t")}" + } else { + val base = debug.deactivatedHelp(plugin, context) + val aggNote = if(thisAggregated.nonEmpty) "Note: This project aggregates other projects and this" else "Note: This" + val common = " information is for this project only." + val helpOther = "To see how to activate this plugin for another project, change to the project using `project ` and rerun this command." + s"$base\n$aggNote$common\n$helpOther" + } + } + } + + /** Precomputes information for debugging natures and plugins. */ + def apply(available: List[AutoPlugin]): NaturesDebug = + { + val keyR = definedKeys(available) + val nameToKey: Map[String, AttributeKey[_]] = keyR._2s.toList.map(key => (key.label, key)).toMap + new NaturesDebug(available, nameToKey, keyR) + } + + /** The context for debugging a plugin (de)activation. + * @param initial The initially defined [[Nature]]s. + * @param enabled The resulting model. + * @param compile The function used to compute the model. + * @param available All [[AutoPlugin]]s available for consideration. */ + final case class Context(initial: Natures, enabled: Seq[AutoPlugin], compile: Natures => Seq[AutoPlugin], available: List[AutoPlugin]) + + /** Describes the steps to activate a plugin in some context. */ + sealed abstract class PluginEnable + /** Describes a [[plugin]] that is already activated in the [[context]].*/ + final case class PluginActivated(plugin: AutoPlugin, context: Context) extends PluginEnable + sealed abstract class EnableDeactivated extends PluginEnable + /** Describes a [[plugin]] that cannot be activated in a [[context]] due to [[contradictions]] in requirements. */ + final case class PluginImpossible(plugin: AutoPlugin, context: Context, contradictions: Set[AutoPlugin]) extends EnableDeactivated + + /** Describes the requirements for activating [[plugin]] in [[context]]. + * @param context The base natures, exclusions, and ultimately activated plugins + * @param blockingExcludes Existing exclusions that prevent [[plugin]] from being activated and must be dropped + * @param enablingNatures [[Nature]]s that are not currently enabled, but need to be enabled for [[plugin]] to activate + * @param extraEnabledPlugins Plugins that will be enabled as a result of [[plugin]] activating, but are not required for [[plugin]] to activate + * @param willRemove Plugins that will be deactivated as a result of [[plugin]] activating + * @param deactivate Describes plugins that must be deactivated for [[plugin]] to activate. These require an explicit exclusion or dropping a transitive [[Nature]].*/ + final case class PluginRequirements(plugin: AutoPlugin, context: Context, blockingExcludes: Set[AutoPlugin], enablingNatures: Set[Nature], extraEnabledPlugins: Set[AutoPlugin], willRemove: Set[AutoPlugin], deactivate: List[DeactivatePlugin]) extends EnableDeactivated + + /** Describes a [[plugin]] that must be removed in order to activate another plugin in some context. + * The [[plugin]] can always be directly, explicitly excluded. + * @param removeOneOf If non-empty, removing one of these [[Nature]]s will deactivate [[plugin]] without affecting the other plugin. If empty, a direct exclusion is required. + * @param newlySelected If false, this plugin was selected in the original context. */ + final case class DeactivatePlugin(plugin: AutoPlugin, removeOneOf: Set[Nature], newlySelected: Boolean) + + /** Determines how to enable [[plugin]] in [[context]]. */ + def pluginEnable(context: Context, plugin: AutoPlugin): PluginEnable = + if(context.enabled.contains(plugin)) + PluginActivated(plugin, context) + else + enableDeactivated(context, plugin) + + private[this] def enableDeactivated(context: Context, plugin: AutoPlugin): PluginEnable = + { + // deconstruct the context + val initialModel = context.enabled.toSet + val initial = flatten(context.initial) + val initialNatures = natures(initial) + val initialExcludes = excludes(initial) + + val minModel = minimalModel(plugin) + + /* example 1 + A :- B, not C + C :- D, E + initial: B, D, E + propose: drop D or E + + initial: B, not A + propose: drop 'not A' + + example 2 + A :- B, not C + C :- B + initial: + propose: B, exclude C + */ + + // `plugin` will only be activated when all of these natures are activated + // Deactivating any one of these would deactivate `plugin`. + val minRequiredNatures = natures(minModel) + + // `plugin` will only be activated when all of these plugins are activated + // Deactivating any one of these would deactivate `plugin`. + val minRequiredPlugins = minModel.collect{ case a: AutoPlugin => a }.toSet + + // The presence of any one of these plugins would deactivate `plugin` + val minAbsentPlugins = excludes(minModel).toSet + + // Plugins that must be both activated and deactivated for `plugin` to activate. + // A non-empty list here cannot be satisfied and is an error. + val contradictions = minAbsentPlugins & minRequiredPlugins + + if(contradictions.nonEmpty) + PluginImpossible(plugin, context, contradictions) + else + { + // Natures that the user has to add to the currently selected natures in order to enable `plugin`. + val addToExistingNatures = minRequiredNatures -- initialNatures + + // Plugins that are currently excluded that need to be allowed. + val blockingExcludes = initialExcludes & minRequiredPlugins + + // The model that results when the minimal natures are enabled and the minimal plugins are excluded. + // This can include more plugins than just `minRequiredPlugins` because the natures required for `plugin` + // might activate other plugins as well. + val modelForMin = context.compile(and(includeAll(minRequiredNatures), excludeAll(minAbsentPlugins))) + + val incrementalInputs = and( includeAll(minRequiredNatures ++ initialNatures), excludeAll(minAbsentPlugins ++ initialExcludes -- minRequiredPlugins)) + val incrementalModel = context.compile(incrementalInputs).toSet + + // Plugins that are newly enabled as a result of selecting the natures needed for `plugin`, but aren't strictly required for `plugin`. + // These could be excluded and `plugin` and the user's current plugins would still be activated. + val extraPlugins = incrementalModel.toSet -- minRequiredPlugins -- initialModel + + // Plugins that will no longer be enabled as a result of enabling `plugin`. + val willRemove = initialModel -- incrementalModel + + // Determine the plugins that must be independently deactivated. + // If both A and B must be deactivated, but A transitively depends on B, deactivating B will deactivate A. + // If A must be deactivated, but one if its (transitively) required natures isn't present, it won't be activated. + // So, in either of these cases, A doesn't need to be considered further and won't be included in this set. + val minDeactivate = minAbsentPlugins.filter(p => Natures.satisfied(p.select, incrementalModel, natures(flatten(incrementalInputs)))) + + val deactivate = for(d <- minDeactivate.toList) yield { + // removing any one of these natures will deactivate `d`. TODO: This is not an especially efficient implementation. + val removeToDeactivate = natures(minimalModel(d)) -- minRequiredNatures + val newlySelected = !initialModel(d) + // a. suggest removing a nature in removeOneToDeactivate to deactivate d + // b. suggest excluding `d` to directly deactivate it in any case + // c. note whether d was already activated (in context.enabled) or is newly selected + DeactivatePlugin(d, removeToDeactivate, newlySelected) + } + + PluginRequirements(plugin, context, blockingExcludes, addToExistingNatures, extraPlugins, willRemove, deactivate) + } + } + + private[this] def includeAll[T <: Basic](basic: Set[T]): Natures = And(basic.toList) + private[this] def excludeAll(plugins: Set[AutoPlugin]): Natures = And(plugins map (p => Exclude(p)) toList) + + private[this] def excludes(bs: Seq[Basic]): Set[AutoPlugin] = bs.collect { case Exclude(b) => b }.toSet + private[this] def natures(bs: Seq[Basic]): Set[Nature] = bs.collect { case n: Nature => n }.toSet + + // If there is a model that includes `plugin`, it includes at least what is returned by this method. + // This is the list of natures and plugins that must be included as well as list of plugins that must not be present. + // It might not be valid, such as if there are contradictions or if there are cycles that are unsatisfiable. + // The actual model might be larger, since other plugins might be enabled by the selected natures. + private[this] def minimalModel(plugin: AutoPlugin): Seq[Basic] = Dag.topologicalSortUnchecked(plugin: Basic) { + case _: Exclude | _: Nature => Nil + case ap: AutoPlugin => Natures.flatten(ap.select) + } + + /** String representation of [[PluginEnable]], intended for end users. */ + def explainPluginEnable(ps: PluginEnable): String = + ps match { + case PluginRequirements(plugin, context, blockingExcludes, enablingNatures, extraEnabledPlugins, toBeRemoved, deactivate) => + def indent(str: String) = if(str.isEmpty) "" else s"\t$str" + def note(str: String) = if(str.isEmpty) "" else s"Note: $str" + val parts = + indent(excludedError(false /* TODO */, blockingExcludes.toList)) :: + indent(required(enablingNatures.toList)) :: + indent(needToDeactivate(deactivate)) :: + note(willAdd(plugin, extraEnabledPlugins.toList)) :: + note(willRemove(plugin, toBeRemoved.toList)) :: + Nil + parts.filterNot(_.isEmpty).mkString("\n") + case PluginImpossible(plugin, context, contradictions) => pluginImpossible(plugin, contradictions) + case PluginActivated(plugin, context) => s"Plugin ${plugin.label} already activated." + } + + /** Provides a [[Relation]] between plugins and the keys they potentially define. + * Because plugins can define keys in different scopes and keys can be overridden, this is not definitive.*/ + def definedKeys(available: List[AutoPlugin]): Relation[AutoPlugin, AttributeKey[_]] = + { + def extractDefinedKeys(ss: Seq[Setting[_]]): Seq[AttributeKey[_]] = + ss.map(_.key.key) + def allSettings(p: AutoPlugin): Seq[Setting[_]] = p.projectSettings ++ p.buildSettings ++ p.globalSettings + val empty = Relation.empty[AutoPlugin, AttributeKey[_]] + (empty /: available)( (r,p) => r + (p, extractDefinedKeys(allSettings(p))) ) + } + + private[this] def excludedError(transitive: Boolean, dependencies: List[AutoPlugin]): String = + str(dependencies)(excludedPluginError(transitive), excludedPluginsError(transitive)) + + private[this] def excludedPluginError(transitive: Boolean)(dependency: AutoPlugin) = + s"Required ${transitiveString(transitive)}dependency ${dependency.label} was excluded." + private[this] def excludedPluginsError(transitive: Boolean)(dependencies: List[AutoPlugin]) = + s"Required ${transitiveString(transitive)}dependencies were excluded:\n\t${labels(dependencies).mkString("\n\t")}" + private[this] def transitiveString(transitive: Boolean) = + if(transitive) "(transitive) " else "" + + private[this] def required(natures: List[Nature]): String = + str(natures)(requiredNature, requiredNatures) + + private[this] def requiredNature(nature: Nature) = + s"Required nature ${nature.label} not present." + private[this] def requiredNatures(natures: List[Nature]) = + s"Required natures not present:\n\t${natures.map(_.label).mkString("\n\t")}" + + private[this] def str[A](list: List[A])(f: A => String, fs: List[A] => String): String = list match { + case Nil => "" + case single :: Nil => f(single) + case _ => fs(list) + } + + private[this] def willAdd(base: AutoPlugin, plugins: List[AutoPlugin]): String = + str(plugins)(willAddPlugin(base), willAddPlugins(base)) + + private[this] def willAddPlugin(base: AutoPlugin)(plugin: AutoPlugin) = + s"Enabling ${base.label} will also enable ${plugin.label}" + private[this] def willAddPlugins(base: AutoPlugin)(plugins: List[AutoPlugin]) = + s"Enabling ${base.label} will also enable:\n\t${labels(plugins).mkString("\n\t")}" + + private[this] def willRemove(base: AutoPlugin, plugins: List[AutoPlugin]): String = + str(plugins)(willRemovePlugin(base), willRemovePlugins(base)) + + private[this] def willRemovePlugin(base: AutoPlugin)(plugin: AutoPlugin) = + s"Enabling ${base.label} will disable ${plugin.label}" + private[this] def willRemovePlugins(base: AutoPlugin)(plugins: List[AutoPlugin]) = + s"Enabling ${base.label} will disable:\n\t${labels(plugins).mkString("\n\t")}" + + private[this] def labels(plugins: List[AutoPlugin]): List[String] = + plugins.map(_.label) + + private[this] def needToDeactivate(deactivate: List[DeactivatePlugin]): String = + str(deactivate)(deactivate1, deactivateN) + private[this] def deactivateN(plugins: List[DeactivatePlugin]): String = + plugins.map(deactivateString).mkString("These plugins need to be deactivated:\n\t", "\n\t", "") + private[this] def deactivate1(deactivate: DeactivatePlugin): String = + s"Need to deactivate ${deactivateString(deactivate)}" + private[this] def deactivateString(d: DeactivatePlugin): String = + { + val removeNaturesString: String = + d.removeOneOf.toList match { + case Nil => "" + case x :: Nil => s" or no longer include $x" + case xs => s" or remove one of ${xs.mkString(", ")}" + } + s"${d.plugin.label}: directly exclude it${removeNaturesString}" + } + + private[this] def pluginImpossible(plugin: AutoPlugin, contradictions: Set[AutoPlugin]): String = + str(contradictions.toList)(pluginImpossible1(plugin), pluginImpossibleN(plugin)) + + private[this] def pluginImpossible1(plugin: AutoPlugin)(contradiction: AutoPlugin): String = + s"There is no way to enable plugin ${plugin.label}. It (or its dependencies) requires plugin ${contradiction.label} to both be present and absent. Please report the problem to the plugin's author." + private[this] def pluginImpossibleN(plugin: AutoPlugin)(contradictions: List[AutoPlugin]): String = + s"There is no way to enable plugin ${plugin.label}. It (or its dependencies) requires these plugins to be both present and absent:\n\t${labels(contradictions).mkString("\n\t")}\nPlease report the problem to the plugin's author." +} \ No newline at end of file diff --git a/main/src/main/scala/sbt/PluginDiscovery.scala b/main/src/main/scala/sbt/PluginDiscovery.scala new file mode 100644 index 000000000..0d49e6fd7 --- /dev/null +++ b/main/src/main/scala/sbt/PluginDiscovery.scala @@ -0,0 +1,135 @@ +package sbt + + import java.io.File + import java.net.URL + import Attributed.data + import Build.analyzed + import xsbt.api.{Discovered,Discovery} + +object PluginDiscovery +{ + /** Relative paths of resources that list top-level modules that are available. + * Normally, the classes for those modules will be in the same classpath entry as the resource. */ + object Paths + { + final val AutoPlugins = "sbt/sbt.autoplugins" + final val Plugins = "sbt/sbt.plugins" + final val Builds = "sbt/sbt.builds" + final val AutoImports = "sbt/sbt.autoimports" + } + /** Names of top-level modules that subclass sbt plugin-related classes: [[Plugin]], [[AutoImport]], [[AutoPlugin]], and [[Build]]. */ + final class DiscoveredNames(val plugins: Seq[String], val autoImports: Seq[String], val autoPlugins: Seq[String], val builds: Seq[String]) + + def emptyDiscoveredNames: DiscoveredNames = new DiscoveredNames(Nil, Nil, Nil, Nil) + + /** Discovers and loads the sbt-plugin-related top-level modules from the classpath and source analysis in `data` and using the provided class `loader`. */ + def discoverAll(data: PluginData, loader: ClassLoader): DetectedPlugins = + { + def discover[T](resource: String)(implicit mf: reflect.ClassManifest[T]) = + binarySourceModules[T](data, loader, resource) + import Paths._ + new DetectedPlugins(discover[Plugin](Plugins), discover[AutoImport](AutoImports), discover[AutoPlugin](AutoPlugins), discover[Build](Builds)) + } + + /** Discovers the sbt-plugin-related top-level modules from the provided source `analysis`. */ + def discoverSourceAll(analysis: inc.Analysis): DiscoveredNames = + { + def discover[T](implicit mf: reflect.ClassManifest[T]): Seq[String] = + sourceModuleNames(analysis, mf.erasure.getName) + new DiscoveredNames(discover[Plugin], discover[AutoImport], discover[AutoPlugin], discover[Build]) + } + + // TODO: for 0.14.0, consider consolidating into a single file, which would make the classpath search 4x faster + /** Writes discovered module `names` to zero or more files in `dir` as per [[writeDescriptor]] and returns the list of files written. */ + def writeDescriptors(names: DiscoveredNames, dir: File): Seq[File] = + { + import Paths._ + val files = + writeDescriptor(names.plugins, dir, Plugins) :: + writeDescriptor(names.autoPlugins, dir, AutoPlugins) :: + writeDescriptor(names.builds, dir, Builds) :: + writeDescriptor(names.autoImports, dir, AutoImports) :: + Nil + files.flatMap(_.toList) + } + + /** Stores the module `names` in `dir / path`, one per line, unless `names` is empty and then the file is deleted and `None` returned. */ + def writeDescriptor(names: Seq[String], dir: File, path: String): Option[File] = + { + val descriptor: File = new File(dir, path) + if(names.isEmpty) + { + IO.delete(descriptor) + None + } + else + { + IO.writeLines(descriptor, names.distinct.sorted) + Some(descriptor) + } + } + + /** Discovers the names of top-level modules listed in resources named `resourceName` as per [[binaryModuleNames]] or + * available as analyzed source and extending from any of `subclasses` as per [[sourceModuleNames]]. */ + def binarySourceModuleNames(classpath: Seq[Attributed[File]], loader: ClassLoader, resourceName: String, subclasses: String*): Seq[String] = + ( + binaryModuleNames(data(classpath), loader, resourceName) ++ + (analyzed(classpath) flatMap ( a => sourceModuleNames(a, subclasses : _*) )) + ).distinct + + /** Discovers top-level modules in `analysis` that inherit from any of `subclasses`. */ + def sourceModuleNames(analysis: inc.Analysis, subclasses: String*): Seq[String] = + { + val subclassSet = subclasses.toSet + val ds = Discovery(subclassSet, Set.empty)(Tests.allDefs(analysis)) + ds.flatMap { + case (definition, Discovered(subs,_,_,true)) => + if((subs & subclassSet).isEmpty) Nil else definition.name :: Nil + case _ => Nil + } + } + + /** Obtains the list of modules identified in all resource files `resourceName` from `loader` that are on `classpath`. + * `classpath` and `loader` are both required to ensure that `loader` + * doesn't bring in any resources outside of the intended `classpath`, such as from parent loaders. */ + def binaryModuleNames(classpath: Seq[File], loader: ClassLoader, resourceName: String): Seq[String] = + { + import collection.JavaConversions._ + loader.getResources(resourceName).toSeq.filter(onClasspath(classpath)) flatMap { u => + IO.readLinesURL(u).map( _.trim).filter(!_.isEmpty) + } + } + + /** Returns `true` if `url` is an entry in `classpath`.*/ + def onClasspath(classpath: Seq[File])(url: URL): Boolean = + IO.urlAsFile(url) exists (classpath.contains _) + + private[sbt] def binarySourceModules[T](data: PluginData, loader: ClassLoader, resourceName: String)(implicit mf: reflect.ClassManifest[T]): DetectedModules[T] = + { + val classpath = data.classpath + val namesAndValues = if(classpath.isEmpty) Nil else { + val names = binarySourceModuleNames(classpath, loader, resourceName, mf.erasure.getName) + loadModules[T](data, names, loader) + } + new DetectedModules(namesAndValues) + } + + private[this] def loadModules[T: ClassManifest](data: PluginData, names: Seq[String], loader: ClassLoader): Seq[(String,T)] = + try ModuleUtilities.getCheckedObjects[T](names, loader) + catch { + case e: ExceptionInInitializerError => + val cause = e.getCause + if(cause eq null) throw e else throw cause + case e: LinkageError => incompatiblePlugins(data, e) + } + + private[this] def incompatiblePlugins(data: PluginData, t: LinkageError): Nothing = + { + val evicted = data.report.toList.flatMap(_.configurations.flatMap(_.evicted)) + val evictedModules = evicted map { id => (id.organization, id.name) } distinct ; + val evictedStrings = evictedModules map { case (o,n) => o + ":" + n } + val msgBase = "Binary incompatibility in plugins detected." + val msgExtra = if(evictedStrings.isEmpty) "" else "\nNote that conflicts were resolved for some dependencies:\n\t" + evictedStrings.mkString("\n\t") + throw new IncompatiblePluginsException(msgBase + msgExtra, t) + } +} \ No newline at end of file diff --git a/main/src/main/scala/sbt/Project.scala b/main/src/main/scala/sbt/Project.scala index db0705299..647013bed 100755 --- a/main/src/main/scala/sbt/Project.scala +++ b/main/src/main/scala/sbt/Project.scala @@ -50,33 +50,52 @@ sealed trait ProjectDefinition[PR <: ProjectReference] /** Configures the sources of automatically appended settings.*/ def auto: AddSettings + /** The [[Natures]] associated with this project. + A [[Nature]] is a common label that is used by plugins to determine what settings, if any, to add to a project. */ + def natures: Natures + + /** The [[AutoPlugin]]s enabled for this project. This value is only available on a loaded Project. */ + private[sbt] def autoPlugins: Seq[AutoPlugin] + override final def hashCode: Int = id.hashCode ^ base.hashCode ^ getClass.hashCode override final def equals(o: Any) = o match { case p: ProjectDefinition[_] => p.getClass == this.getClass && p.id == id && p.base == base case _ => false } - override def toString = "Project(id: " + id + ", base: " + base + ", aggregate: " + aggregate + ", dependencies: " + dependencies + ", configurations: " + configurations + ")" + override def toString = + { + val agg = ifNonEmpty("aggregate", aggregate) + val dep = ifNonEmpty("dependencies", dependencies) + val conf = ifNonEmpty("configurations", configurations) + val autos = ifNonEmpty("autoPlugins", autoPlugins.map(_.label)) + val fields = s"id $id" :: s"base: $base" :: agg ::: dep ::: conf ::: (s"natures: List($natures)" :: autos) + s"Project(${fields.mkString(", ")})" + } + private[this] def ifNonEmpty[T](label: String, ts: Iterable[T]): List[String] = if(ts.isEmpty) Nil else s"$label: $ts" :: Nil } sealed trait Project extends ProjectDefinition[ProjectReference] { + // TODO: add parameters for natures and autoPlugins in 0.14.0 (not reasonable to do in a binary compatible way in 0.13) def copy(id: String = id, base: File = base, aggregate: => Seq[ProjectReference] = aggregate, dependencies: => Seq[ClasspathDep[ProjectReference]] = dependencies, delegates: => Seq[ProjectReference] = delegates, settings: => Seq[Setting[_]] = settings, configurations: Seq[Configuration] = configurations, auto: AddSettings = auto): Project = - Project(id, base, aggregate = aggregate, dependencies = dependencies, delegates = delegates, settings, configurations, auto) + unresolved(id, base, aggregate = aggregate, dependencies = dependencies, delegates = delegates, settings, configurations, auto, natures, autoPlugins) def resolve(resolveRef: ProjectReference => ProjectRef): ResolvedProject = { def resolveRefs(prs: Seq[ProjectReference]) = prs map resolveRef def resolveDeps(ds: Seq[ClasspathDep[ProjectReference]]) = ds map resolveDep def resolveDep(d: ClasspathDep[ProjectReference]) = ResolvedClasspathDependency(resolveRef(d.project), d.configuration) - resolved(id, base, aggregate = resolveRefs(aggregate), dependencies = resolveDeps(dependencies), delegates = resolveRefs(delegates), settings, configurations, auto) + resolved(id, base, aggregate = resolveRefs(aggregate), dependencies = resolveDeps(dependencies), delegates = resolveRefs(delegates), + settings, configurations, auto, natures, autoPlugins) } def resolveBuild(resolveRef: ProjectReference => ProjectReference): Project = { def resolveRefs(prs: Seq[ProjectReference]) = prs map resolveRef def resolveDeps(ds: Seq[ClasspathDep[ProjectReference]]) = ds map resolveDep def resolveDep(d: ClasspathDep[ProjectReference]) = ClasspathDependency(resolveRef(d.project), d.configuration) - apply(id, base, aggregate = resolveRefs(aggregate), dependencies = resolveDeps(dependencies), delegates = resolveRefs(delegates), settings, configurations, auto) + unresolved(id, base, aggregate = resolveRefs(aggregate), dependencies = resolveDeps(dependencies), delegates = resolveRefs(delegates), + settings, configurations, auto, natures, autoPlugins) } /** Applies the given functions to this Project. @@ -116,8 +135,30 @@ sealed trait Project extends ProjectDefinition[ProjectReference] /** Sets the list of .sbt files to parse for settings to be appended to this project's settings. * Any configured .sbt files are removed from this project's list.*/ def setSbtFiles(files: File*): Project = copy(auto = AddSettings.append( AddSettings.clearSbtFiles(auto), AddSettings.sbtFiles(files: _*)) ) + + /** Sets the [[Nature]]s of this project. + A [[Nature]] is a common label that is used by plugins to determine what settings, if any, to add to a project. */ + def addNatures(ns: Nature*): Project = setNatures(Natures.and(natures, Natures.And(ns.toList))) + + /** Disable the given plugins on this project. */ + def disablePlugins(plugins: AutoPlugin*): Project = + setNatures(Natures.and(natures, Natures.And(plugins.map(p => Natures.Exclude(p)).toList))) + + private[this] def setNatures(ns: Natures): Project = { + // TODO: for 0.14.0, use copy when it has the additional `natures` parameter + unresolved(id, base, aggregate = aggregate, dependencies = dependencies, delegates = delegates, settings, configurations, auto, ns, autoPlugins) + } + + /** Definitively set the [[AutoPlugin]]s for this project. */ + private[sbt] def setAutoPlugins(autos: Seq[AutoPlugin]): Project = { + // TODO: for 0.14.0, use copy when it has the additional `autoPlugins` parameter + unresolved(id, base, aggregate = aggregate, dependencies = dependencies, delegates = delegates, settings, configurations, auto, natures, autos) + } +} +sealed trait ResolvedProject extends ProjectDefinition[ProjectRef] { + /** The [[AutoPlugin]]s enabled for this project as computed from [[natures]].*/ + def autoPlugins: Seq[AutoPlugin] } -sealed trait ResolvedProject extends ProjectDefinition[ProjectRef] sealed trait ClasspathDep[PR <: ProjectReference] { def project: PR; def configuration: Option[String] } final case class ResolvedClasspathDependency(project: ProjectRef, configuration: Option[String]) extends ClasspathDep[ProjectRef] @@ -150,23 +191,22 @@ object Project extends ProjectExtra Def.showRelativeKey( ProjectRef(loaded.root, loaded.units(loaded.root).rootProjects.head), loaded.allProjectRefs.size > 1, keyNameColor) private abstract class ProjectDef[PR <: ProjectReference](val id: String, val base: File, aggregate0: => Seq[PR], dependencies0: => Seq[ClasspathDep[PR]], - delegates0: => Seq[PR], settings0: => Seq[Def.Setting[_]], val configurations: Seq[Configuration], val auto: AddSettings) extends ProjectDefinition[PR] + delegates0: => Seq[PR], settings0: => Seq[Def.Setting[_]], val configurations: Seq[Configuration], val auto: AddSettings, + val natures: Natures, val autoPlugins: Seq[AutoPlugin]) extends ProjectDefinition[PR] { lazy val aggregate = aggregate0 lazy val dependencies = dependencies0 lazy val delegates = delegates0 lazy val settings = settings0 - + Dag.topologicalSort(configurations)(_.extendsConfigs) // checks for cyclic references here instead of having to do it in Scope.delegates } + // TODO: add parameter for natures in 0.14.0 def apply(id: String, base: File, aggregate: => Seq[ProjectReference] = Nil, dependencies: => Seq[ClasspathDep[ProjectReference]] = Nil, delegates: => Seq[ProjectReference] = Nil, settings: => Seq[Def.Setting[_]] = defaultSettings, configurations: Seq[Configuration] = Configurations.default, auto: AddSettings = AddSettings.allDefaults): Project = - { - validProjectID(id).foreach(errMsg => sys.error("Invalid project ID: " + errMsg)) - new ProjectDef[ProjectReference](id, base, aggregate, dependencies, delegates, settings, configurations, auto) with Project - } + unresolved(id, base, aggregate, dependencies, delegates, settings, configurations, auto, Natures.empty, Nil) /** Returns None if `id` is a valid Project ID or Some containing the parser error message if it is not.*/ def validProjectID(id: String): Option[String] = DefaultParsers.parse(id, DefaultParsers.ID).left.toOption @@ -185,9 +225,23 @@ object Project extends ProjectExtra * This is a best effort implementation, since valid characters are not documented or consistent.*/ def normalizeModuleID(id: String): String = normalizeBase(id) + @deprecated("Will be removed.", "0.13.2") def resolved(id: String, base: File, aggregate: => Seq[ProjectRef], dependencies: => Seq[ResolvedClasspathDependency], delegates: => Seq[ProjectRef], settings: Seq[Def.Setting[_]], configurations: Seq[Configuration], auto: AddSettings): ResolvedProject = - new ProjectDef[ProjectRef](id, base, aggregate, dependencies, delegates, settings, configurations, auto) with ResolvedProject + resolved(id, base, aggregate, dependencies, delegates, settings, configurations, auto, Natures.empty, Nil) + + private def resolved(id: String, base: File, aggregate: => Seq[ProjectRef], dependencies: => Seq[ClasspathDep[ProjectRef]], + delegates: => Seq[ProjectRef], settings: Seq[Def.Setting[_]], configurations: Seq[Configuration], auto: AddSettings, + natures: Natures, autoPlugins: Seq[AutoPlugin]): ResolvedProject = + new ProjectDef[ProjectRef](id, base, aggregate, dependencies, delegates, settings, configurations, auto, natures, autoPlugins) with ResolvedProject + + private def unresolved(id: String, base: File, aggregate: => Seq[ProjectReference], dependencies: => Seq[ClasspathDep[ProjectReference]], + delegates: => Seq[ProjectReference], settings: => Seq[Def.Setting[_]], configurations: Seq[Configuration], auto: AddSettings, + natures: Natures, autoPlugins: Seq[AutoPlugin]): Project = + { + validProjectID(id).foreach(errMsg => sys.error("Invalid project ID: " + errMsg)) + new ProjectDef[ProjectReference](id, base, aggregate, dependencies, delegates, settings, configurations, auto, natures, autoPlugins) with Project + } def defaultSettings: Seq[Def.Setting[_]] = Defaults.defaultSettings @@ -307,7 +361,7 @@ object Project extends ProjectExtra def details(structure: BuildStructure, actual: Boolean, scope: Scope, key: AttributeKey[_])(implicit display: Show[ScopedKey[_]]): String = { val scoped = ScopedKey(scope,key) - + val data = scopedKeyData(structure, scope, key) map {_.description} getOrElse {"No entry for key."} val description = key.description match { case Some(desc) => "Description:\n\t" + desc + "\n"; case None => "" } @@ -413,7 +467,7 @@ object Project extends ProjectExtra import DefaultParsers._ val loadActionParser = token(Space ~> ("plugins" ^^^ Plugins | "return" ^^^ Return)) ?? Current - + val ProjectReturn = AttributeKey[List[File]]("project-return", "Maintains a stack of builds visited using reload.") def projectReturn(s: State): List[File] = getOrNil(s, ProjectReturn) def inPluginProject(s: State): Boolean = projectReturn(s).toList.length > 1 diff --git a/project/Sbt.scala b/project/Sbt.scala index b4c820dea..956c368d4 100644 --- a/project/Sbt.scala +++ b/project/Sbt.scala @@ -73,6 +73,8 @@ object Sbt extends Build lazy val datatypeSub = baseProject(utilPath /"datatype", "Datatype Generator") dependsOn(ioSub) // cross versioning lazy val crossSub = baseProject(utilPath / "cross", "Cross") settings(inConfig(Compile)(Transform.crossGenSettings): _*) + // A logic with restricted negation as failure for a unique, stable model + lazy val logicSub = testedBaseProject(utilPath / "logic", "Logic").dependsOn(collectionSub, relationSub) /* **** Intermediate-level Modules **** */ @@ -130,7 +132,7 @@ object Sbt extends Build completeSub, classpathSub, stdTaskSub, processSub) settings( sbinary ) // The main integration project for sbt. It brings all of the subsystems together, configures them, and provides for overriding conventions. - lazy val mainSub = testedBaseProject(mainPath, "Main") dependsOn(actionsSub, mainSettingsSub, interfaceSub, ioSub, ivySub, launchInterfaceSub, logSub, processSub, runSub, commandSub) settings(scalaXml) + lazy val mainSub = testedBaseProject(mainPath, "Main") dependsOn(actionsSub, mainSettingsSub, interfaceSub, ioSub, ivySub, launchInterfaceSub, logSub, logicSub, processSub, runSub, commandSub) settings(scalaXml) // Strictly for bringing implicits and aliases from subsystems into the top-level sbt namespace through a single package object // technically, we need a dependency on all of mainSub's dependencies, but we don't do that since this is strictly an integration project diff --git a/sbt/src/sbt-test/project/auto-plugins/build.sbt b/sbt/src/sbt-test/project/auto-plugins/build.sbt new file mode 100644 index 000000000..f48a1f0e5 --- /dev/null +++ b/sbt/src/sbt-test/project/auto-plugins/build.sbt @@ -0,0 +1,34 @@ +// excludePlugins(C) will prevent C, and thus D, from being auto-added +lazy val a = project.addNatures(A, B).disablePlugins(Q) + +// without B, C is not added +lazy val b = project.addNatures(A) + +// with both A and B, C is selected, which in turn selects D +lazy val c = project.addNatures(A, B) + +// with no natures defined, nothing is auto-added +lazy val d = project + + +check := { + val ddel = (del in d).?.value // should be None + same(ddel, None, "del in d") + val bdel = (del in b).?.value // should be None + same(bdel, None, "del in b") + val adel = (del in a).?.value // should be None + same(adel, None, "del in a") +// + val buildValue = (demo in ThisBuild).value + same(buildValue, "build 0", "demo in ThisBuild") + val globalValue = (demo in Global).value + same(globalValue, "global 0", "demo in Global") + val projValue = (demo in c).value + same(projValue, "project c Q R", "demo in c") + val qValue = (del in c in q).value + same(qValue, " Q R", "del in c in q") +} + +def same[T](actual: T, expected: T, label: String) { + assert(actual == expected, s"Expected '$expected' for `$label`, got '$actual'") +} \ No newline at end of file diff --git a/sbt/src/sbt-test/project/auto-plugins/project/Q.scala b/sbt/src/sbt-test/project/auto-plugins/project/Q.scala new file mode 100644 index 000000000..db51922cf --- /dev/null +++ b/sbt/src/sbt-test/project/auto-plugins/project/Q.scala @@ -0,0 +1,59 @@ + import sbt._ + import sbt.Keys.{name, resolvedScoped} + import java.util.concurrent.atomic.{AtomicInteger => AInt} + +object AI extends AutoImport +{ + lazy val A = Nature("A") + lazy val B = Nature("B") + lazy val D = Nature("D") + + lazy val q = config("q") + lazy val p = config("p").extend(q) + + lazy val demo = settingKey[String]("A demo setting.") + lazy val del = settingKey[String]("Another demo setting.") + + lazy val check = settingKey[Unit]("Verifies settings are as they should be.") +} + + import AI._ + +object Q extends AutoPlugin +{ + def select: Natures = A && B + + override def projectConfigurations: Seq[Configuration] = + p :: + q :: + Nil + + override def projectSettings: Seq[Setting[_]] = + (demo := s"project ${name.value}") :: + (del in q := " Q") :: + Nil + + override def buildSettings: Seq[Setting[_]] = + (demo := s"build ${buildCount.getAndIncrement}") :: + Nil + + override def globalSettings: Seq[Setting[_]] = + (demo := s"global ${globalCount.getAndIncrement}") :: + Nil + + // used to ensure the build-level and global settings are only added once + private[this] val buildCount = new AInt(0) + private[this] val globalCount = new AInt(0) +} + +object R extends AutoPlugin +{ + def select = Q && !D + + override def projectSettings = Seq( + // tests proper ordering: R requires C, so C settings should come first + del in q += " R", + // tests that configurations are properly registered, enabling delegation from p to q + demo += (del in p).value + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/project/auto-plugins/test b/sbt/src/sbt-test/project/auto-plugins/test new file mode 100644 index 000000000..15675b169 --- /dev/null +++ b/sbt/src/sbt-test/project/auto-plugins/test @@ -0,0 +1 @@ +> check diff --git a/sbt/src/sbt-test/project/binary-plugin/changes/define/A.scala b/sbt/src/sbt-test/project/binary-plugin/changes/define/A.scala new file mode 100644 index 000000000..c38558d4f --- /dev/null +++ b/sbt/src/sbt-test/project/binary-plugin/changes/define/A.scala @@ -0,0 +1,21 @@ +import sbt._ +import Keys._ + + +object C extends AutoImport { + lazy val bN = Nature("B") + lazy val check = taskKey[Unit]("Checks that the AutoPlugin and Build are automatically added.") +} + + import C._ + +object A extends AutoPlugin { + override def select = bN + override def projectSettings = Seq( + check := {} + ) +} + +object B extends Build { + lazy val extra = project.addNatures(bN) +} diff --git a/sbt/src/sbt-test/project/binary-plugin/changes/define/build.sbt b/sbt/src/sbt-test/project/binary-plugin/changes/define/build.sbt new file mode 100644 index 000000000..f8a8d32b8 --- /dev/null +++ b/sbt/src/sbt-test/project/binary-plugin/changes/define/build.sbt @@ -0,0 +1,3 @@ +sbtPlugin := true + +name := "demo-plugin" diff --git a/sbt/src/sbt-test/project/binary-plugin/changes/use/plugins.sbt b/sbt/src/sbt-test/project/binary-plugin/changes/use/plugins.sbt new file mode 100644 index 000000000..b20bc97c3 --- /dev/null +++ b/sbt/src/sbt-test/project/binary-plugin/changes/use/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.example" % "demo-plugin" % "3.4") diff --git a/sbt/src/sbt-test/project/binary-plugin/common.sbt b/sbt/src/sbt-test/project/binary-plugin/common.sbt new file mode 100644 index 000000000..0cf61c76c --- /dev/null +++ b/sbt/src/sbt-test/project/binary-plugin/common.sbt @@ -0,0 +1,7 @@ +organization in ThisBuild := "org.example" + +version in ThisBuild := "3.4" + +lazy val define = project + +lazy val use = project diff --git a/sbt/src/sbt-test/project/binary-plugin/test b/sbt/src/sbt-test/project/binary-plugin/test new file mode 100644 index 000000000..ceb4e6a76 --- /dev/null +++ b/sbt/src/sbt-test/project/binary-plugin/test @@ -0,0 +1,10 @@ +$ copy-file changes/define/build.sbt build.sbt +$ copy-file changes/define/A.scala A.scala + +# reload implied +> publishLocal + +$ delete build.sbt A.scala +$ copy-file changes/use/plugins.sbt project/plugins.sbt +> reload +> extra/check diff --git a/util/classpath/src/main/scala/sbt/ModuleUtilities.scala b/util/classpath/src/main/scala/sbt/ModuleUtilities.scala index d939c040b..69dfa31dc 100644 --- a/util/classpath/src/main/scala/sbt/ModuleUtilities.scala +++ b/util/classpath/src/main/scala/sbt/ModuleUtilities.scala @@ -6,7 +6,7 @@ package sbt object ModuleUtilities { /** Reflectively loads and returns the companion object for top-level class `className` from `loader`. - * The class name should not include the `$` that scalac appends to the underlying jvm class for + * The class name should not include the `$` that scalac appends to the underlying jvm class for * a companion object. */ def getObject(className: String, loader: ClassLoader): AnyRef = { @@ -14,4 +14,10 @@ object ModuleUtilities val singletonField = obj.getField("MODULE$") singletonField.get(null) } + + def getCheckedObject[T](className: String, loader: ClassLoader)(implicit mf: reflect.ClassManifest[T]): T = + mf.erasure.cast(getObject(className, loader)).asInstanceOf[T] + + def getCheckedObjects[T](classNames: Seq[String], loader: ClassLoader)(implicit mf: reflect.ClassManifest[T]): Seq[(String,T)] = + classNames.map(name => (name, getCheckedObject(name, loader))) } \ No newline at end of file diff --git a/util/collection/src/main/scala/sbt/Dag.scala b/util/collection/src/main/scala/sbt/Dag.scala index 4250b0f10..f0594ed50 100644 --- a/util/collection/src/main/scala/sbt/Dag.scala +++ b/util/collection/src/main/scala/sbt/Dag.scala @@ -15,7 +15,7 @@ object Dag import JavaConverters.asScalaSetConverter def topologicalSort[T](root: T)(dependencies: T => Iterable[T]): List[T] = topologicalSort(root :: Nil)(dependencies) - + def topologicalSort[T](nodes: Iterable[T])(dependencies: T => Iterable[T]): List[T] = { val discovered = new mutable.HashSet[T] @@ -24,7 +24,7 @@ object Dag def visitAll(nodes: Iterable[T]) = nodes foreach visit def visit(node : T){ if (!discovered(node)) { - discovered(node) = true; + discovered(node) = true; try { visitAll(dependencies(node)); } catch { case c: Cyclic => throw node :: c } finished += node; } @@ -33,11 +33,13 @@ object Dag } visitAll(nodes); - + finished.toList; } // doesn't check for cycles - def topologicalSortUnchecked[T](node: T)(dependencies: T => Iterable[T]): List[T] = + def topologicalSortUnchecked[T](node: T)(dependencies: T => Iterable[T]): List[T] = topologicalSortUnchecked(node :: Nil)(dependencies) + + def topologicalSortUnchecked[T](nodes: Iterable[T])(dependencies: T => Iterable[T]): List[T] = { val discovered = new mutable.HashSet[T] var finished: List[T] = Nil @@ -45,23 +47,23 @@ object Dag def visitAll(nodes: Iterable[T]) = nodes foreach visit def visit(node : T){ if (!discovered(node)) { - discovered(node) = true; + discovered(node) = true; visitAll(dependencies(node)) finished ::= node; } } - visit(node); + visitAll(nodes); finished; } final class Cyclic(val value: Any, val all: List[Any], val complete: Boolean) extends Exception( "Cyclic reference involving " + - (if(complete) all.mkString("\n ", "\n ", "") else value) + (if(complete) all.mkString("\n ", "\n ", "") else value) ) { def this(value: Any) = this(value, value :: Nil, false) override def toString = getMessage - def ::(a: Any): Cyclic = + def ::(a: Any): Cyclic = if(complete) this else if(a == value) @@ -69,5 +71,62 @@ object Dag else new Cyclic(value, a :: all, false) } -} + /** A directed graph with edges labeled positive or negative. */ + private[sbt] trait DirectedSignedGraph[Node] + { + /** Directed edge type that tracks the sign and target (head) vertex. + * The sign can be obtained via [[isNegative]] and the target vertex via [[head]]. */ + type Arrow + /** List of initial nodes. */ + def nodes: List[Arrow] + /** Outgoing edges for `n`. */ + def dependencies(n: Node): List[Arrow] + /** `true` if the edge `a` is "negative", false if it is "positive". */ + def isNegative(a: Arrow): Boolean + /** The target of the directed edge `a`. */ + def head(a: Arrow): Node + } + + /** Traverses a directed graph defined by `graph` looking for a cycle that includes a "negative" edge. + * The directed edges are weighted by the caller as "positive" or "negative". + * If a cycle containing a "negative" edge is detected, its member edges are returned in order. + * Otherwise, the empty list is returned. */ + private[sbt] def findNegativeCycle[Node](graph: DirectedSignedGraph[Node]): List[graph.Arrow] = + { + import scala.annotation.tailrec + import graph._ + val finished = new mutable.HashSet[Node] + val visited = new mutable.HashSet[Node] + + def visit(edges: List[Arrow], stack: List[Arrow]): List[Arrow] = edges match { + case Nil => Nil + case edge :: tail => + val node = head(edge) + if(!visited(node)) + { + visited += node + visit(dependencies(node), edge :: stack) match { + case Nil => + finished += node + visit(tail, stack) + case cycle => cycle + } + } + else if(!finished(node)) + { + // cycle. If a negative edge is involved, it is an error. + val between = edge :: stack.takeWhile(f => head(f) != node) + if(between exists isNegative) + between + else + visit(tail, stack) + } + else + visit(tail, stack) + } + + visit(graph.nodes, Nil) + } + +} diff --git a/util/logic/src/main/scala/sbt/logic/Logic.scala b/util/logic/src/main/scala/sbt/logic/Logic.scala new file mode 100644 index 000000000..4eb8e64b1 --- /dev/null +++ b/util/logic/src/main/scala/sbt/logic/Logic.scala @@ -0,0 +1,325 @@ +package sbt +package logic + + import scala.annotation.tailrec + import Formula.{And, True} + +/* +Defines a propositional logic with negation as failure and only allows stratified rule sets (negation must be acyclic) in order to have a unique minimal model. + +For example, this is not allowed: + + p :- not q + + q :- not p +but this is: + + p :- q + + q :- p +as is this: + + p :- q + + q := not r + + + Some useful links: + + https://en.wikipedia.org/wiki/Nonmonotonic_logic + + https://en.wikipedia.org/wiki/Negation_as_failure + + https://en.wikipedia.org/wiki/Propositional_logic + + https://en.wikipedia.org/wiki/Stable_model_semantics + + http://www.w3.org/2005/rules/wg/wiki/negation +*/ + + +/** Disjunction (or) of the list of clauses. */ +final case class Clauses(clauses: List[Clause]) { + assert(clauses.nonEmpty, "At least one clause is required.") +} + +/** When the `body` Formula succeeds, atoms in `head` are true. */ +final case class Clause(body: Formula, head: Set[Atom]) + +/** A literal is an [[Atom]] or its [[negation|Negated]]. */ +sealed abstract class Literal extends Formula { + /** The underlying (positive) atom. */ + def atom: Atom + /** Negates this literal.*/ + def unary_! : Literal +} +/** A variable with name `label`. */ +final case class Atom(label: String) extends Literal { + def atom = this + def unary_! : Negated = Negated(this) +} +/** A negated atom, in the sense of negation as failure, not logical negation. +* That is, it is true if `atom` is not known/defined. */ +final case class Negated(atom: Atom) extends Literal { + def unary_! : Atom = atom +} + +/** A formula consists of variables, negation, and conjunction (and). +* (Disjunction is not currently included- it is modeled at the level of a sequence of clauses. +* This is less convenient when defining clauses, but is not less powerful.) */ +sealed abstract class Formula { + /** Constructs a clause that proves `atoms` when this formula is true. */ + def proves(atom: Atom, atoms: Atom*): Clause = Clause(this, (atom +: atoms).toSet) + + /** Constructs a formula that is true iff this formula and `f` are both true.*/ + def && (f: Formula): Formula = (this, f) match { + case (True, x) => x + case (x, True) => x + case (And(as), And(bs)) => And(as ++ bs) + case (And(as), b: Literal) => And(as + b) + case (a: Literal, And(bs)) => And(bs + a) + case (a: Literal, b: Literal) => And( Set(a,b) ) + } +} + + +object Formula { + /** A conjunction of literals. */ + final case class And(literals: Set[Literal]) extends Formula { + assert(literals.nonEmpty, "'And' requires at least one literal.") + } + final case object True extends Formula +} + +object Logic +{ + def reduceAll(clauses: List[Clause], initialFacts: Set[Literal]): Either[LogicException, Matched] = + reduce(Clauses(clauses), initialFacts) + + /** Computes the variables in the unique stable model for the program represented by `clauses` and `initialFacts`. + * `clause` may not have any negative feedback (that is, negation is acyclic) + * and `initialFacts` cannot be in the head of any clauses in `clause`. + * These restrictions ensure that the logic program has a unique minimal model. */ + def reduce(clauses: Clauses, initialFacts: Set[Literal]): Either[LogicException, Matched] = + { + val (posSeq, negSeq) = separate(initialFacts.toSeq) + val (pos, neg) = (posSeq.toSet, negSeq.toSet) + + val problem = + checkContradictions(pos, neg) orElse + checkOverlap(clauses, pos) orElse + checkAcyclic(clauses) + + problem.toLeft( + reduce0(clauses, initialFacts, Matched.empty) + ) + } + + + /** Verifies `initialFacts` are not in the head of any `clauses`. + * This avoids the situation where an atom is proved but no clauses prove it. + * This isn't necessarily a problem, but the main sbt use cases expects + * a proven atom to have at least one clause satisfied. */ + private[this] def checkOverlap(clauses: Clauses, initialFacts: Set[Atom]): Option[InitialOverlap] = { + val as = atoms(clauses) + val initialOverlap = initialFacts.filter(as.inHead) + if(initialOverlap.nonEmpty) Some(new InitialOverlap(initialOverlap)) else None + } + + private[this] def checkContradictions(pos: Set[Atom], neg: Set[Atom]): Option[InitialContradictions] = { + val contradictions = pos intersect neg + if(contradictions.nonEmpty) Some(new InitialContradictions(contradictions)) else None + } + + private[this] def checkAcyclic(clauses: Clauses): Option[CyclicNegation] = { + val deps = dependencyMap(clauses) + val cycle = Dag.findNegativeCycle(graph(deps)) + if(cycle.nonEmpty) Some(new CyclicNegation(cycle)) else None + } + private[this] def graph(deps: Map[Atom, Set[Literal]]) = new Dag.DirectedSignedGraph[Atom] { + type Arrow = Literal + def nodes = deps.keys.toList + def dependencies(a: Atom) = deps.getOrElse(a, Set.empty).toList + def isNegative(b: Literal) = b match { + case Negated(_) => true + case Atom(_) => false + } + def head(b: Literal) = b.atom + } + + private[this] def dependencyMap(clauses: Clauses): Map[Atom, Set[Literal]] = + (Map.empty[Atom, Set[Literal]] /: clauses.clauses) { + case (m, Clause(formula, heads)) => + val deps = literals(formula) + (m /: heads) { (n, head) => n.updated(head, n.getOrElse(head, Set.empty) ++ deps) } + } + + sealed abstract class LogicException(override val toString: String) + final class InitialContradictions(val literals: Set[Atom]) extends LogicException("Initial facts cannot be both true and false:\n\t" + literals.mkString("\n\t")) + final class InitialOverlap(val literals: Set[Atom]) extends LogicException("Initial positive facts cannot be implied by any clauses:\n\t" + literals.mkString("\n\t")) + final class CyclicNegation(val cycle: List[Literal]) extends LogicException("Negation may not be involved in a cycle:\n\t" + cycle.mkString("\n\t")) + + /** Tracks proven atoms in the reverse order they were proved. */ + final class Matched private(val provenSet: Set[Atom], reverseOrdered: List[Atom]) { + def add(atoms: Set[Atom]): Matched = add(atoms.toList) + def add(atoms: List[Atom]): Matched = { + val newOnly = atoms.filterNot(provenSet) + new Matched(provenSet ++ newOnly, newOnly ::: reverseOrdered) + } + def ordered: List[Atom] = reverseOrdered.reverse + override def toString = ordered.map(_.label).mkString("Matched(", ",", ")") + } + object Matched { + val empty = new Matched(Set.empty, Nil) + } + + /** Separates a sequence of literals into `(pos, neg)` atom sequences. */ + private[this] def separate(lits: Seq[Literal]): (Seq[Atom], Seq[Atom]) = Util.separate(lits) { + case a: Atom => Left(a) + case Negated(n) => Right(n) + } + + /** Finds clauses that have no body and thus prove their head. + * Returns `(, )`. */ + private[this] def findProven(c: Clauses): (Set[Atom], List[Clause]) = + { + val (proven, unproven) = c.clauses.partition(_.body == True) + (proven.flatMap(_.head).toSet, unproven) + } + private[this] def keepPositive(lits: Set[Literal]): Set[Atom] = + lits.collect{ case a: Atom => a}.toSet + + // precondition: factsToProcess contains no contradictions + @tailrec + private[this] def reduce0(clauses: Clauses, factsToProcess: Set[Literal], state: Matched): Matched = + applyAll(clauses, factsToProcess) match { + case None => // all of the remaining clauses failed on the new facts + state + case Some(applied) => + val (proven, unprovenClauses) = findProven(applied) + val processedFacts = state add keepPositive(factsToProcess) + val newlyProven = proven -- processedFacts.provenSet + val newState = processedFacts add newlyProven + if(unprovenClauses.isEmpty) + newState // no remaining clauses, done. + else { + val unproven = Clauses(unprovenClauses) + val nextFacts: Set[Literal] = if(newlyProven.nonEmpty) newlyProven.toSet else inferFailure(unproven) + reduce0(unproven, nextFacts, newState) + } + } + + /** Finds negated atoms under the negation as failure rule and returns them. + * This should be called only after there are no more known atoms to be substituted. */ + private[this] def inferFailure(clauses: Clauses): Set[Literal] = + { + /* At this point, there is at least one clause and one of the following is the case as the result of the acyclic negation rule: + i. there is at least one variable that occurs in a clause body but not in the head of a clause + ii. there is at least one variable that occurs in the head of a clause and does not transitively depend on a negated variable + In either case, each such variable x cannot be proven true and therefore proves 'not x' (negation as failure, !x in the code). + */ + val allAtoms = atoms(clauses) + val newFacts: Set[Literal] = negated(allAtoms.triviallyFalse) + if(newFacts.nonEmpty) + newFacts + else { + val possiblyTrue = hasNegatedDependency(clauses.clauses, Relation.empty, Relation.empty) + val newlyFalse: Set[Literal] = negated(allAtoms.inHead -- possiblyTrue) + if(newlyFalse.nonEmpty) + newlyFalse + else // should never happen due to the acyclic negation rule + error(s"No progress:\n\tclauses: $clauses\n\tpossibly true: $possiblyTrue") + } + } + + private[this] def negated(atoms: Set[Atom]): Set[Literal] = atoms.map(a => Negated(a)) + + /** Computes the set of atoms in `clauses` that directly or transitively take a negated atom as input. + * For example, for the following clauses, this method would return `List(a, d)` : + * a :- b, not c + * d :- a + */ + @tailrec + def hasNegatedDependency(clauses: Seq[Clause], posDeps: Relation[Atom, Atom], negDeps: Relation[Atom, Atom]): List[Atom] = + clauses match { + case Seq() => + // because cycles between positive literals are allowed, this isn't strictly a topological sort + Dag.topologicalSortUnchecked(negDeps._1s)(posDeps.reverse) + case Clause(formula, head) +: tail => + // collect direct positive and negative literals and track them in separate graphs + val (pos, neg) = directDeps(formula) + val (newPos, newNeg) = ( (posDeps, negDeps) /: head) { case ( (pdeps, ndeps), d) => + (pdeps + (d, pos), ndeps + (d, neg) ) + } + hasNegatedDependency(tail, newPos, newNeg) + } + + /** Computes the `(positive, negative)` literals in `formula`. */ + private[this] def directDeps(formula: Formula): (Seq[Atom], Seq[Atom]) = + Util.separate(literals(formula).toSeq) { + case Negated(a) => Right(a) + case a: Atom => Left(a) + } + private[this] def literals(formula: Formula): Set[Literal] = formula match { + case And(lits) => lits + case l: Literal => Set(l) + case True => Set.empty + } + + /** Computes the atoms in the heads and bodies of the clauses in `clause`. */ + def atoms(cs: Clauses): Atoms = cs.clauses.map(c => Atoms(c.head, atoms(c.body))).reduce(_ ++ _) + + /** Computes the set of all atoms in `formula`. */ + def atoms(formula: Formula): Set[Atom] = formula match { + case And(lits) => lits.map(_.atom) + case Negated(lit) => Set(lit) + case a: Atom => Set(a) + case True => Set() + } + + /** Represents the set of atoms in the heads of clauses and in the bodies (formulas) of clauses. */ + final case class Atoms(val inHead: Set[Atom], val inFormula: Set[Atom]) { + /** Concatenates this with `as`. */ + def ++ (as: Atoms): Atoms = Atoms(inHead ++ as.inHead, inFormula ++ as.inFormula) + /** Atoms that cannot be true because they do not occur in a head. */ + def triviallyFalse: Set[Atom] = inFormula -- inHead + } + + /** Applies known facts to `clause`s, deriving a new, possibly empty list of clauses. + * 1. If a fact is in the body of a clause, the derived clause has that fact removed from the body. + * 2. If the negation of a fact is in a body of a clause, that clause fails and is removed. + * 3. If a fact or its negation is in the head of a clause, the derived clause has that fact (or its negation) removed from the head. + * 4. If a head is empty, the clause proves nothing and is removed. + * + * NOTE: empty bodies do not cause a clause to succeed yet. + * All known facts must be applied before this can be done in order to avoid inconsistencies. + * Precondition: no contradictions in `facts` + * Postcondition: no atom in `facts` is present in the result + * Postcondition: No clauses have an empty head + * */ + def applyAll(cs: Clauses, facts: Set[Literal]): Option[Clauses] = + { + val newClauses = + if(facts.isEmpty) + cs.clauses.filter(_.head.nonEmpty) // still need to drop clauses with an empty head + else + cs.clauses.map(c => applyAll(c, facts)).flatMap(_.toList) + if(newClauses.isEmpty) None else Some(Clauses(newClauses)) + } + + def applyAll(c: Clause, facts: Set[Literal]): Option[Clause] = + { + val atoms = facts.map(_.atom) + val newHead = c.head -- atoms // 3. + if(newHead.isEmpty) // 4. empty head + None + else + substitute(c.body, facts).map( f => Clause(f, newHead) ) // 1, 2 + } + + /** Derives the formula that results from substituting `facts` into `formula`. */ + @tailrec + def substitute(formula: Formula, facts: Set[Literal]): Option[Formula] = formula match { + case And(lits) => + def negated(lits: Set[Literal]): Set[Literal] = lits.map(a => !a) + if( lits.exists( negated(facts) ) ) // 2. + None + else { + val newLits = lits -- facts + val newF = if(newLits.isEmpty) True else And(newLits) + Some(newF) // 1. + } + case True => Some(True) + case lit: Literal => // define in terms of And + substitute(And(Set(lit)), facts) + } +} diff --git a/util/logic/src/test/scala/sbt/logic/Test.scala b/util/logic/src/test/scala/sbt/logic/Test.scala new file mode 100644 index 000000000..cf50ef9fd --- /dev/null +++ b/util/logic/src/test/scala/sbt/logic/Test.scala @@ -0,0 +1,117 @@ +package sbt +package logic + + import org.scalacheck._ + import Prop.secure + import Logic.{LogicException, Matched} + +object LogicTest extends Properties("Logic") +{ + import TestClauses._ + + property("Handles trivial resolution.") = secure( expect(trivial, Set(A) ) ) + property("Handles less trivial resolution.") = secure( expect(lessTrivial, Set(B,A,D)) ) + property("Handles cycles without negation") = secure( expect(cycles, Set(F,A,B)) ) + property("Handles basic exclusion.") = secure( expect(excludedPos, Set()) ) + property("Handles exclusion of head proved by negation.") = secure( expect(excludedNeg, Set()) ) + // TODO: actually check ordering, probably as part of a check that dependencies are satisifed + property("Properly orders results.") = secure( expect(ordering, Set(B,A,C,E,F))) + property("Detects cyclic negation") = secure( + Logic.reduceAll(badClauses, Set()) match { + case Right(res) => false + case Left(err: Logic.CyclicNegation) => true + case Left(err) => error(s"Expected cyclic error, got: $err") + } + ) + + def expect(result: Either[LogicException, Matched], expected: Set[Atom]) = result match { + case Left(err) => false + case Right(res) => + val actual = res.provenSet + (actual == expected) || error(s"Expected to prove $expected, but actually proved $actual") + } +} + +object TestClauses +{ + + val A = Atom("A") + val B = Atom("B") + val C = Atom("C") + val D = Atom("D") + val E = Atom("E") + val F = Atom("F") + val G = Atom("G") + + val clauses = + A.proves(B) :: + A.proves(F) :: + B.proves(F) :: + F.proves(A) :: + (!C).proves(F) :: + D.proves(C) :: + C.proves(D) :: + Nil + + val cycles = Logic.reduceAll(clauses, Set()) + + val badClauses = + A.proves(D) :: + clauses + + val excludedNeg = { + val cs = + (!A).proves(B) :: + Nil + val init = + (!A) :: + (!B) :: + Nil + Logic.reduceAll(cs, init.toSet) + } + + val excludedPos = { + val cs = + A.proves(B) :: + Nil + val init = + A :: + (!B) :: + Nil + Logic.reduceAll(cs, init.toSet) + } + + val trivial = { + val cs = + Formula.True.proves(A) :: + Nil + Logic.reduceAll(cs, Set.empty) + } + + val lessTrivial = { + val cs = + Formula.True.proves(A) :: + Formula.True.proves(B) :: + (A && B && (!C)).proves(D) :: + Nil + Logic.reduceAll(cs, Set()) + } + + val ordering = { + val cs = + E.proves(F) :: + (C && !D).proves(E) :: + (A && B).proves(C) :: + Nil + Logic.reduceAll(cs, Set(A,B)) + } + + def all { + println(s"Cycles: $cycles") + println(s"xNeg: $excludedNeg") + println(s"xPos: $excludedPos") + println(s"trivial: $trivial") + println(s"lessTrivial: $lessTrivial") + println(s"ordering: $ordering") + } +} diff --git a/util/relation/src/main/scala/sbt/Relation.scala b/util/relation/src/main/scala/sbt/Relation.scala index 725512d0b..77c0b70c2 100644 --- a/util/relation/src/main/scala/sbt/Relation.scala +++ b/util/relation/src/main/scala/sbt/Relation.scala @@ -40,7 +40,7 @@ object Relation private[sbt] def get[X,Y](map: M[X,Y], t: X): Set[Y] = map.getOrElse(t, Set.empty[Y]) - private[sbt] type M[X,Y] = Map[X, Set[Y]] + private[sbt] type M[X,Y] = Map[X, Set[Y]] } /** Binary relation between A and B. It is a set of pairs (_1, _2) for _1 in A, _2 in B. */ @@ -111,7 +111,7 @@ private final class MRelation[A,B](fwd: Map[A, Set[B]], rev: Map[B, Set[A]]) ext { def forwardMap = fwd def reverseMap = rev - + def forward(t: A) = get(fwd, t) def reverse(t: B) = get(rev, t) @@ -119,12 +119,12 @@ private final class MRelation[A,B](fwd: Map[A, Set[B]], rev: Map[B, Set[A]]) ext def _2s = rev.keySet def size = (fwd.valuesIterator map { _.size }).foldLeft(0)(_ + _) - + def all: Traversable[(A,B)] = fwd.iterator.flatMap { case (a, bs) => bs.iterator.map( b => (a,b) ) }.toTraversable def +(pair: (A,B)) = this + (pair._1, Set(pair._2)) def +(from: A, to: B) = this + (from, to :: Nil) - def +(from: A, to: Traversable[B]) = + def +(from: A, to: Traversable[B]) = if(to.isEmpty) this else new MRelation( add(fwd, from, to), (rev /: to) { (map, t) => add(map, t, from :: Nil) }) def ++(rs: Traversable[(A,B)]) = ((this: Relation[A,B]) /: rs) { _ + _ }