Created
December 17, 2011 05:41
-
-
Save havocp/1489363 to your computer and use it in GitHub Desktop.
Hacky build-lint and new-help commands
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.typesafe.sbthelp | |
import _root_.sbt._ | |
import Project.Initialize | |
import Keys._ | |
import Defaults._ | |
import Scope.GlobalScope | |
import Load.BuildStructure | |
import scala.annotation.tailrec | |
import scala.collection.generic.CanBuildFrom | |
import scala.xml | |
/** This plugin is a temporary holding place to develop a | |
* lint command and improved help/inspect commands. | |
*/ | |
object HelpAndLintPlugin extends Plugin { | |
import Markup.{ List => _, _ } | |
private lazy val defaultKeysThatCanBeUnused: Seq[ScopedKey[_]] = | |
// The way this works is that these act as requested | |
// dependencies (anything that would be a delegate of | |
// these is counted as used, unless it's overridden by a | |
// definition in which case it is not) | |
Seq(silenceUnusedWarning, | |
// 'commands' is special-cased because we set | |
// *:commands below as part of the plugin and it's | |
// then unused. it seems like setting commands in | |
// Global below would be right, but it doesn't work. | |
// anyway on integration into sbt this would go away. | |
commands, | |
// TODO I don't understand why these need to be | |
// special-cased; I can't find where sbt even | |
// sets them. | |
configuration in Optional, | |
configuration in Provided, | |
// the global scope version of this is in the | |
// settings list, but the cross build command | |
// also uses the project scope version | |
crossScalaVersions | |
).map(_.scopedKey) ++ | |
(Project.defaultSettings ++ Defaults.buildCore).map(_.key) | |
override lazy val settings = Seq(commands ++= Seq(lintCommand, | |
helpCommand, | |
helpBrowserCommand), | |
silenceUnusedWarning := defaultKeysThatCanBeUnused) | |
// debug examples | |
val someKey = TaskKey[String]("some-key") | |
val otherKey = TaskKey[String]("other-key") | |
val thirdKey = TaskKey[String]("third-key") | |
val fourthKey = TaskKey[String]("fourth-key") | |
// cut-and-paste from Settings.scala to add selfDeps, | |
// should move into sbt proper | |
def compiled(init: Seq[Setting[_]], actual: Boolean = true, selfDeps: Boolean = false)(implicit delegates: Scope => Seq[Scope], scopeLocal: Project.ScopeLocal, display: Show[ScopedKey[_]]): Project.CompiledMap = | |
{ | |
// prepend per-scope settings | |
val withLocal = Project.addLocal(init)(scopeLocal) | |
// group by Scope/Key, dropping dead initializations | |
val sMap: Project.ScopedMap = Project.grouped(withLocal) | |
// delegate references to undefined values according to 'delegates' | |
val dMap: Project.ScopedMap = if(actual) Project.delegate(sMap)(delegates, display) else sMap | |
// merge Seq[Setting[_]] into Compiled | |
compile(dMap, selfDeps) | |
} | |
// cut-and-paste from Settings.scala to add selfDeps, | |
// should move into sbt proper | |
def compile(sMap: Project.ScopedMap, selfDeps: Boolean = false): Project.CompiledMap = | |
sMap.toTypedSeq.map { case sMap.TPair(k, ss) => | |
val deps = ss flatMap { ss => if (selfDeps) ss.init.dependencies else ss.dependencies } toSet; | |
(k, new Project.Compiled(k, deps, ss)) | |
} toMap; | |
/** Analysis of a project used for presenting help and lint information. | |
* All lazy-computed since it may not all be needed in a given run | |
* of the command. | |
*/ | |
class ProjectAnalysis(state: State) { | |
lazy val extracted: Extracted = Project.extract(state) | |
lazy val structure: BuildStructure = extracted.structure | |
lazy implicit val display = | |
Project.showContextKey(extracted.session, extracted.structure) | |
def markup(sk: ScopedKey[_]): Span = { | |
val withScope = display(sk) | |
ScopedKeyLink(withScope, sk.key.label, withScope, sk) | |
} | |
private def hyphenToCamel(s: String): String = { | |
val sb = new java.lang.StringBuilder() | |
s.foldLeft(false)({ | |
(justSawHyphen, c) => | |
if (c == '-') { | |
true | |
} else if (justSawHyphen) { | |
sb.appendCodePoint(Character.toUpperCase(c)) | |
false | |
} else { | |
sb.appendCodePoint(c) | |
false | |
} | |
}) | |
sb.toString | |
} | |
// returns a Scala version of the key if we can come up with | |
// one that seems likely to work. | |
private def displayScala(key: ScopedKey[_]): Option[String] = { | |
val current = extracted.session.current | |
val scopesOption = try { | |
val projectScope = | |
key.scope.project match { | |
case Global => | |
Seq("GlobalScope") | |
case Select(BuildRef(current.build)) => | |
Seq("ThisBuild") | |
case Select(current) => | |
Nil | |
case This => | |
Nil | |
} | |
val configScope = | |
key.scope.config match { | |
case Select(c) if Configurations.default.exists(_.name == c.name) => | |
Seq(c.name.substring(0, 1).toUpperCase + c.name.substring(1)) | |
case Global => | |
Nil | |
case This => | |
Nil | |
} | |
val taskScope = | |
key.scope.task match { | |
case Select(t) => | |
Seq(hyphenToCamel(t.label)) | |
case Global => | |
Nil | |
case This => | |
Nil | |
} | |
Some(projectScope ++ configScope ++ taskScope) | |
} catch { | |
case ex: MatchError => | |
None | |
} | |
scopesOption map { | |
scopes => | |
(hyphenToCamel(key.key.label) +: scopes).mkString(" in ") | |
} | |
} | |
def markupAkaInScala(key: ScopedKey[_]): Option[Span] = { | |
displayScala(key).map(s => Code(Seq(Text(s)))) | |
} | |
// Flattened(key:ScopedKey,dependencies:Iterable[ScopedKey]) | |
lazy val actualCMap: Map[ScopedKey[_],Project.Flattened] = | |
Project.flattenLocals(Project.compiled(structure.settings, actual=true)(structure.delegates, structure.scopeLocal, display)) | |
lazy val requestedCMap: Map[ScopedKey[_],Project.Flattened] = | |
Project.flattenLocals(compiled(structure.settings, actual=false, selfDeps=true)(structure.delegates, structure.scopeLocal, display)) | |
// these are both just lists of all known keys I guess | |
private lazy val actualKeys = actualCMap.keys | |
private lazy val requestedKeys = requestedCMap.keys | |
case class TriggerKey(cause: ScopedKey[_], | |
effects: Seq[ScopedKey[_]]) | |
lazy val triggers: Seq[TriggerKey] = { | |
def key(task: Task[_]): ScopedKey[_] = | |
structure.index.taskToKey.get(task).get | |
val result = | |
for ((cause, effects) <- structure.index.triggers.injectFor) | |
yield TriggerKey(key(cause), effects map(key(_))) | |
result.toSeq | |
} | |
// map causes to effects | |
private lazy val causeToEffectsMap: Map[ScopedKey[_], Seq[ScopedKey[_]]] = | |
triggers.foldLeft(Map.empty[ScopedKey[_], Seq[ScopedKey[_]]])({ | |
(sofar, next) => sofar + (next.cause -> next.effects) | |
}) | |
def triggerEffects(cause: ScopedKey[_]): Seq[ScopedKey[_]] = { | |
causeToEffectsMap.get(cause).getOrElse(Nil) | |
} | |
private lazy val effectToCausesMap: Map[ScopedKey[_], Seq[ScopedKey[_]]] = | |
triggers.foldLeft(Map.empty[ScopedKey[_], Seq[ScopedKey[_]]])({ | |
(sofar, next) => | |
next.effects.foldLeft(sofar)({ | |
(sofar, nextEffect) => | |
val olderCauses = | |
sofar.get(nextEffect).getOrElse(Nil) | |
sofar + (nextEffect -> (olderCauses :+ next.cause)) | |
}) | |
}) | |
def triggerCauses(effect: ScopedKey[_]): Seq[ScopedKey[_]] = { | |
effectToCausesMap.get(effect).getOrElse(Nil) | |
} | |
lazy val allTriggerCauses: Set[ScopedKey[_]] = { | |
causeToEffectsMap.keys.toSet | |
} | |
lazy val allTriggerEffects: Set[ScopedKey[_]] = { | |
effectToCausesMap.keys.toSet | |
} | |
def isTriggerCause(key: ScopedKey[_]) = | |
allTriggerCauses.contains(key) | |
lazy val actualUsed: Set[ScopedKey[_]] = | |
actualCMap.values.flatMap(_.dependencies).toSet ++ | |
allTriggerEffects ++ allTriggerCauses | |
lazy val actualUnused: Set[ScopedKey[_]] = | |
actualKeys.filter(!actualUsed.contains(_)).toSet | |
lazy val requestedUsed: Set[ScopedKey[_]] = | |
requestedCMap.values.flatMap(_.dependencies).toSet ++ | |
allTriggerEffects ++ allTriggerCauses | |
lazy val requestedUnused: Set[ScopedKey[_]] = | |
requestedKeys.filter(!requestedUsed.contains(_)).toSet | |
def keyIsDefined(key: ScopedKey[_]) = | |
actualCMap.contains(key) | |
def actualScopedKeys(key: AttributeKey[_]): Set[ScopedKey[_]] = | |
actualKeys.filter(_.key == key).toSet | |
def requestedScopedKeys(key: AttributeKey[_]): Set[ScopedKey[_]] = | |
requestedKeys.filter(_.key == key).toSet | |
def scopedKeys(key: AttributeKey[_]): Set[ScopedKey[_]] = | |
actualScopedKeys(key) ++ requestedScopedKeys(key) | |
def requesters(key: ScopedKey[_]): Set[ScopedKey[_]] = | |
requestedCMap.values.filter(_.dependencies.exists(_ == key)).map(_.key).toSet | |
def users(key: ScopedKey[_]): Set[ScopedKey[_]] = | |
actualCMap.values.filter(_.dependencies.exists(_ == key)).map(_.key).toSet ++ | |
triggerCauses(key) ++ triggerEffects(key) | |
def actualDependencies(key: ScopedKey[_]): Iterable[ScopedKey[_]] = | |
actualCMap.get(key).toSeq.flatMap(_.dependencies) | |
def requestedDependencies(key: ScopedKey[_]): Iterable[ScopedKey[_]] = | |
requestedCMap.get(key).toSeq.flatMap(_.dependencies) | |
def delegates(key: ScopedKey[_]): Iterable[ScopedKey[_]] = | |
Project.delegates(structure, key.scope, key.key) | |
def guessIntended(key: ScopedKey[_]): Option[ScopedKey[_]] = { | |
Project.guessIntendedScope((actualUsed ++ requestedUsed).toSeq, | |
structure.delegates, | |
key) | |
} | |
} | |
private def analyze(state: State): ProjectAnalysis = { | |
return new ProjectAnalysis(state) | |
} | |
/** Convert a collection of A into a collection of Seq[A], where | |
* each Seq[A] is a list of adjacent elements that a predicate | |
* says to group together. | |
*/ | |
private def condense[A, Coll[A] <: Traversable[A]](c: Coll[A], groupTogether: (A, A) => Boolean)(implicit bf: CanBuildFrom[Coll[A], Seq[A], Coll[Seq[A]]]): Coll[Seq[A]] = { | |
val b = bf(c) | |
// acc is built up backward then reversed | |
@tailrec | |
def makeGroups(acc: List[A], remaining: Traversable[A]): Unit = { | |
if (remaining.isEmpty) { | |
b += acc.reverse | |
} else if (acc.isEmpty || | |
groupTogether(acc.head, remaining.head)) { | |
makeGroups(remaining.head :: acc, remaining.tail) | |
} else { | |
b += acc.reverse | |
makeGroups(Nil, remaining) | |
} | |
} | |
makeGroups(Nil, c) | |
b.result | |
} | |
private def spansChainToBlock(chain: Seq[Seq[Span]]): Block = { | |
BlockList(chain.zipWithIndex.map({ | |
spansAndIndex => | |
Indented(IndentedAfterFirst(Paragraph(spansAndIndex._1, verticalSpaceAfter=false)), | |
count = spansAndIndex._2) | |
})) | |
} | |
private def markupDepChain(a: ProjectAnalysis, user: ScopedKey[_], requested: ScopedKey[_], defined: ScopedKey[_]): Block = { | |
val initialLinks: Seq[Seq[Span]] = | |
Seq(Seq(a.markup(user)), | |
Seq(Text("depends on "), a.markup(requested))) | |
// it should be true that either "defined" is in this | |
// list of delegates, or "defined == requested" | |
val delegates = a.delegates(requested) | |
val condensed = condense(delegates, { | |
(first: ScopedKey[_], second: ScopedKey[_]) => | |
if (first == defined || second == defined) | |
// "defined" is always by itself | |
false | |
else if (first == requested || second == requested) | |
// "requested" is always by itself | |
false | |
else | |
// group undefined and defined keys | |
a.keyIsDefined(first) == a.keyIsDefined(second) | |
}) | |
def markupDelegates(delegates: Seq[ScopedKey[_]]): Seq[Span] = { | |
val markups = delegates.map(a.markup(_)) | |
if (markups.size > 1) { | |
// this has unfortunate algorithmic complexity, | |
// fix it if the lists get long ... | |
markups.tail.foldLeft(Seq(markups.head))({ | |
(sofar, next) => | |
sofar ++ Seq(Text(", "), next) | |
}) | |
} else { | |
markups | |
} | |
} | |
val chain: Seq[Seq[Span]] = initialLinks ++ condensed.map({ | |
delegates => | |
val delegate = delegates.head | |
if (delegate == defined) { | |
Seq(Bold(Text("delegates to "), a.markup(delegate))) | |
} else if (a.keyIsDefined(delegate)) { | |
if (delegate == requested) | |
Seq(Text("skips circular dependency "), a.markup(delegate)) | |
else | |
Text("overrides ") +: markupDelegates(delegates) | |
} else { | |
Text("skips undefined ") +: markupDelegates(delegates) | |
} | |
}) | |
spansChainToBlock(chain) | |
} | |
private def markupTriggeredBy(a: ProjectAnalysis, cause: ScopedKey[_], effect: ScopedKey[_]): Block = { | |
spansChainToBlock(Seq(Seq(a.markup(effect)), Seq(Text("triggered by "), a.markup(cause)))) | |
} | |
private def markupTriggers(a: ProjectAnalysis, cause: ScopedKey[_], effect: ScopedKey[_]): Block = { | |
spansChainToBlock(Seq(Seq(a.markup(cause)), Seq(Text("triggers "), a.markup(effect)))) | |
} | |
private def markupSummary(a: ProjectAnalysis, key: ScopedKey[_]): Block = { | |
Paragraph(Seq(Text(key.key.label)) ++ key.key.description.map(desc => Seq(Text(" " + desc))).getOrElse(Seq.empty)) | |
} | |
private def markupUsers(a: ProjectAnalysis, defined: ScopedKey[_]): Block = { | |
val users = a.users(defined).toSeq.sortBy(a.display(_)) | |
val blocksBuilder = Seq.newBuilder[Block] | |
if (users.nonEmpty) { | |
for (u <- users) { | |
// we only want to display one way that u makes use | |
// of the key; the main time we have two ways is | |
// that if u is triggered by us, we're also a | |
// dependency of u | |
val howUsed = { | |
for (cause <- a.triggerCauses(u) | |
if cause == defined) | |
yield markupTriggeredBy(a, cause, u) | |
} ++ { | |
for (effect <- a.triggerEffects(u) | |
if effect == defined) | |
yield markupTriggers(a, u, effect) | |
} ++ { | |
for (requestedDep <- a.requestedDependencies(u).find(_.key == defined.key)) | |
yield markupDepChain(a, u, requestedDep, defined) | |
} head | |
blocksBuilder += howUsed | |
} | |
} else { | |
blocksBuilder += spansChainToBlock(Seq(Seq(a.markup(defined)), | |
Seq(Text("is defined, but isn't used anywhere")))) | |
} | |
BlockList(blocksBuilder.result) | |
} | |
private def markupHelp(a: ProjectAnalysis, key: ScopedKey[_]): Block = { | |
val blockBuilder = Seq.newBuilder[Block] | |
blockBuilder += markupSummary(a, key) | |
val actualScopes = a.actualScopedKeys(key.key).toSeq.sortBy(a.display(_)) | |
blockBuilder += Paragraph(ScopedKeyLink(key.key.label, | |
key.key.label, | |
key.key.label, | |
key), | |
Text(" is defined in these scopes:")) | |
def spansToTable(pairs: Iterable[Seq[Span]]): Table = { | |
val rows = pairs.zipWithIndex.toSeq.map({ | |
rowWithIndex => | |
rowWithIndex._1.map(col => | |
IndentedAfterFirst(Paragraph(Seq(col), | |
verticalSpaceAfter=(rowWithIndex._2 == 0)))) | |
}) | |
Table(rows) | |
} | |
def keyTableFromSpans(pairs: Iterable[Seq[Span]]): Table = { | |
spansToTable(Seq(Seq(Bold(Text("Key")), | |
Bold(Text("Project.settings syntax")))) ++ pairs) | |
} | |
val definedTable = | |
for (k <- actualScopes) | |
yield Seq(a.markup(k), | |
a.markupAkaInScala(k).getOrElse(Text(""))) | |
blockBuilder += Indented(keyTableFromSpans(definedTable)) | |
blockBuilder += Indented(Paragraph(Text("(to override these, a setting must use the same or a more specific scope)"))) | |
val requested = a.requestedUsed filter { | |
k => k.key == key.key | |
} | |
if (requested.nonEmpty) { | |
blockBuilder += Paragraph(Text("Other settings depend on '" + key.key.label + "' in these scopes:")) | |
val requestedTable = | |
for (i <- requested.toSeq.sortBy(a.display(_))) | |
yield Seq(a.markup(i), | |
a.markupAkaInScala(i).getOrElse(Text(""))) | |
blockBuilder += Indented(keyTableFromSpans(requestedTable)) | |
blockBuilder += Indented(Paragraph(Text("(to be used, a setting's scope must be identical to or less-specific than these)"))) | |
} | |
blockBuilder += Paragraph(ScopedKeyLink(key.key.label, | |
key.key.label, | |
key.key.label, | |
key), | |
Text(" is used in these ways:")) | |
for (defined <- actualScopes) { | |
blockBuilder += Indented(markupUsers(a, defined)) | |
} | |
BlockList(blockBuilder.result) | |
} | |
private def help(state: State, log: Logger, key: ScopedKey[_]): Unit = { | |
val a = analyze(state) | |
val doc = markupHelp(a, key) | |
log.info(renderText(doc, | |
// TODO get actual terminal properties | |
RenderTextOptions(wrapWidth = 100, | |
ansiCodes=true))) | |
} | |
// the Command here doesn't really matter, we'll move the guts | |
// of it over to the actual help command | |
lazy val helpCommand = | |
Command("new-help", ("help <key>", "Help!"), "")(BuiltinCommands.optSpacedKeyParser) { | |
(state: State, maybeKey: Option[ScopedKey[_]]) => | |
val log = CommandSupport.logger(state) | |
if (maybeKey.isDefined) { | |
help(state, log, maybeKey.get) | |
} else { | |
log.info("Use help <key name> for help on a key") | |
} | |
state | |
} | |
case class KeyHelpFiles(docs: Map[String, Block]) { | |
def add(label: String, doc: Block): KeyHelpFiles = { | |
KeyHelpFiles(docs + (label -> doc)) | |
} | |
def add(a: ProjectAnalysis, key: ScopedKey[_]): KeyHelpFiles = { | |
if (docs.contains(key.key.label)) { | |
this | |
} else { | |
val doc = markupHelp(a, key) | |
add(key.key.label, doc).addDeps(a, doc) | |
} | |
} | |
def addDeps(a: ProjectAnalysis, node: Node): KeyHelpFiles = { | |
node match { | |
case ScopedKeyLink(_, _, _, key) => | |
add(a, key) | |
case _ => | |
node.children.foldLeft(this)({ | |
(sofar, child) => | |
sofar.addDeps(a, child) | |
}) | |
} | |
} | |
} | |
def helpBrowser(state: State, log: Logger, key: ScopedKey[_]): Unit = { | |
val a = analyze(state) | |
val targetDir = a.extracted.get(target) | |
val helpDir = targetDir / "help" | |
helpDir.mkdir() | |
val startDoc = markupHelp(a, key) | |
val files = KeyHelpFiles(Map(key.key.label -> startDoc)).addDeps(a, startDoc) | |
// TODO this generates a whole lot of files sometimes, | |
// need to be smarter about when to regenerate, etc. | |
// or just switch to rendering on-demand instead | |
// of generating files, of course. | |
for ((label, doc) <- files.docs) { | |
val helpFile = helpDir / (label + ".html") | |
val options = RenderHtmlOptions(currentKey=Some(label)) | |
IO.write(helpFile, | |
renderHtmlDocument(title=label, block=doc, | |
options=options)) | |
} | |
val uri = (helpDir / (key.key.label + ".html")).toURI | |
log.info("Opening " + uri.toASCIIString) | |
// this API is from Java 6. | |
java.awt.Desktop.getDesktop().browse(uri) | |
} | |
lazy val helpBrowserCommand = | |
Command("help-browser", ("help-browser <key>", "Show help about a key in your web browser"), "")(BuiltinCommands.optSpacedKeyParser) { | |
(state: State, maybeKey: Option[ScopedKey[_]]) => | |
val log = CommandSupport.logger(state) | |
if (maybeKey.isDefined) { | |
helpBrowser(state, log, maybeKey.get) | |
} else { | |
log.info("Use help-browser <key name> for help on a key") | |
} | |
state | |
} | |
private def projectsAreRelated(key: ScopedKey[_], other: ScopedKey[_]): Boolean = { | |
other.scope.project match { | |
case Select(x) => | |
Select(x) == key.scope.project | |
case _ => | |
true | |
} | |
} | |
// try to chop down a list of keys to the most-related ones | |
private def fewerKeys(a: ProjectAnalysis, key: ScopedKey[_], more: Iterable[ScopedKey[_]]): Seq[ScopedKey[_]] = { | |
val desiredSize = 5 | |
// "not showing 1 other keys" is both bad grammar | |
// and seems kind of aggravating (why hide just one key?). | |
// so we try to avoid that. | |
if (more.size < desiredSize + 2) { | |
more.toSeq | |
} else { | |
// we first sort alphabetically, which nicely puts the | |
// global keys at the front since they start with * | |
// and then we partition so we show different-project | |
// keys only after showing global, build, or same-project | |
// keys. | |
val (fewer, others) = | |
more.toSeq.sortBy(a.display(_)).partition(projectsAreRelated(key, _)) | |
if (fewer.size < desiredSize) { | |
fewer ++ others.take(desiredSize - fewer.size) | |
} else { | |
// note we don't truncate this; always show all related keys. | |
// this can be a lot if a key is in a bunch of configuration | |
// or task scopes, but in those cases knowing those scopes | |
// is important. It's only long lists of project scopes | |
// that we can feel pretty confident aren't useful. | |
fewer | |
} | |
} | |
} | |
private def markupFewerNote(fewer: Iterable[ScopedKey[_]], | |
more: Iterable[ScopedKey[_]]): Option[Block] = { | |
val omitted = more.size - fewer.size | |
if (omitted > 0) | |
Some(Paragraph(Seq(Text("(not showing " + omitted + " other related keys)")), | |
verticalSpaceAfter=false)) | |
else | |
None | |
} | |
private def markupUnused(a: ProjectAnalysis, unused: ScopedKey[_]): Block = { | |
val blockBuilder = Seq.newBuilder[Block] | |
blockBuilder += Warning(Paragraph(Seq(a.markup(unused), | |
Text(" is defined but not used")))) | |
val actual = a.actualUsed filter { _.key == unused.key } | |
val (overriders, allNonOverriders) = actual partition { sk => a.delegates(sk).exists(_ == unused) } | |
val nonOverriders = | |
allNonOverriders.filter(a.actualUsed.contains(_)) | |
val intendedScopeOption = a.guessIntended(unused) | |
for (intendedScope <- intendedScopeOption) { | |
// the "overrides" message is better if it applies | |
// so skip this one in that case. | |
if (!overriders.contains(intendedScope)) { | |
blockBuilder += | |
Indented(Paragraph(Seq(Text("You may have intended '"), | |
a.markup(intendedScope), | |
Text("' rather than '"), | |
a.markup(unused), | |
Text("'?")))) | |
} | |
} | |
for (o <- overriders) { | |
blockBuilder += | |
Indented(Paragraph(Seq(Text("The more-specific definition '"), | |
a.markup(o), | |
Text("' may override '"), | |
a.markup(unused), | |
Text("'")))) | |
} | |
val fewerNonOverriders = fewerKeys(a, unused, nonOverriders) | |
if (fewerNonOverriders.nonEmpty) { | |
blockBuilder += | |
Indented(Paragraph(Seq(Text("These other definitions of '"), | |
ScopedKeyLink(unused.key.label, | |
unused.key.label, | |
unused.key.label, | |
unused), | |
Text("' are used:")))) | |
val nonOverridersBuilder = Seq.newBuilder[Block] | |
for (i <- fewerNonOverriders) { | |
nonOverridersBuilder += | |
Indented(Paragraph(Seq(a.markup(i)), verticalSpaceAfter=false), | |
count = 2) | |
} | |
for (note <- markupFewerNote(fewerNonOverriders, nonOverriders)) { | |
nonOverridersBuilder += Indented(note, count = 2) | |
} | |
blockBuilder += BlockList(nonOverridersBuilder.result, | |
verticalSpaceAfter = true) | |
blockBuilder += | |
Indented(Paragraph(Text("(perhaps you meant to set one of those instead?)")), | |
count = 2) | |
} | |
val requested = a.requestedUsed filter { | |
k => k.key == unused.key && k != unused | |
} | |
val fewerRequested = fewerKeys(a, unused, requested) | |
if (fewerRequested.nonEmpty) { | |
blockBuilder += | |
Indented(Paragraph(Seq(Text("Other settings depend on '"), | |
ScopedKeyLink(unused.key.label, | |
unused.key.label, | |
unused.key.label, | |
unused), | |
Text("' using these scopes:")))) | |
val requestedBuilder = Seq.newBuilder[Block] | |
for (i <- fewerRequested) { | |
requestedBuilder += | |
Indented(Paragraph(Seq(a.markup(i)), | |
verticalSpaceAfter = false), | |
count = 2) | |
} | |
for (note <- markupFewerNote(fewerRequested, requested)) { | |
requestedBuilder += Indented(note, count = 2) | |
} | |
blockBuilder += BlockList(requestedBuilder.result, | |
verticalSpaceAfter = true) | |
blockBuilder += | |
Indented(Paragraph(Seq(Text("(to be used, a setting's scope must be identical to or less-specific than these requested dependencies)"))), | |
count = 2) | |
} | |
blockBuilder += | |
Indented(Paragraph(Seq(Text("Try 'new-help "), | |
ScopedKeyLink(unused.key.label, | |
unused.key.label, | |
unused.key.label, | |
unused), | |
Text("' for more information.")))) | |
BlockList(blockBuilder.result) | |
} | |
val silenceUnusedWarning = | |
SettingKey[Seq[ScopedKey[_]]]("silence-unused-warning", | |
"List of scoped keys build-lint should not warn about if they are unused; use This scopes as wildcards") | |
// scope the ignoreable-keys list to a project | |
private def ignoreableForScope(a: ProjectAnalysis, | |
ignoreableWithThisScopes: Set[ScopedKey[_]], | |
projectScope: ScopeAxis[Reference]): Set[ScopedKey[_]] = { | |
projectScope match { | |
case Select(ref) => { | |
val resolved = Scope.resolveReference(a.extracted.currentRef.build, | |
a.extracted.rootProject, | |
ref) | |
val scoper = Scope.resolveScope(Load.projectScope(resolved), | |
a.extracted.currentRef.build, | |
a.extracted.rootProject) | |
ignoreableWithThisScopes.map({ | |
ignoreableWithThis => | |
Project.ScopedKey(scoper(ignoreableWithThis.scope), | |
ignoreableWithThis.key) | |
}) | |
} | |
case _ => { | |
ignoreableWithThisScopes | |
} | |
} | |
} | |
// for each key we can ignore if it's unused, also ignore its | |
// delegates up to the first _defined_ delegate. Do not ignore | |
// anything after the defined delegate. So the ignore-list | |
// works as a "user" that is using keys it might delegate to. | |
private def addDelegatesToIgnoreable(a: ProjectAnalysis, | |
ignoreables: Set[ScopedKey[_]]): Set[ScopedKey[_]] = { | |
ignoreables.foldLeft(ignoreables)({ | |
(sofar, sk) => | |
val delegates = a.delegates(sk) | |
sofar ++ delegates.takeWhile(!a.keyIsDefined(_)) | |
}) | |
} | |
private def markupLint(state: State): Option[Block] = { | |
val a = analyze(state) | |
// sanity checks (TODO remove later) | |
for (u <- a.actualUsed) { | |
require(a.actualUsed.contains(u)) | |
require(!a.actualUnused.contains(u)) | |
require(a.actualCMap.contains(u)) | |
} | |
for (u <- a.actualUnused) { | |
require(!a.actualUsed.contains(u)) | |
require(a.actualUnused.contains(u)) | |
require(a.actualCMap.contains(u)) | |
} | |
// the silence-unused-warning list is allowed to have | |
// unresolved This scopes in it in order to silence | |
// keys in any project in a multi-project build. | |
val ignoreableWithThisScopes = | |
a.extracted.getOpt(silenceUnusedWarning).getOrElse(Nil).toSet | |
val unusedByProjectScope = a.actualUnused.groupBy(_.scope.project) | |
// Ignore warnings on keys that are in the list | |
val ignored = { | |
for ((projectScope, keys) <- unusedByProjectScope) | |
yield { | |
val scopedIgnoreable = | |
addDelegatesToIgnoreable(a, | |
ignoreableForScope(a, | |
ignoreableWithThisScopes, | |
projectScope)) | |
keys.filter(scopedIgnoreable.contains(_)) | |
} | |
}.reduce(_ ++ _) | |
// and we will warn about these | |
val warnAbout = a.actualUnused.filter(!ignored.contains(_)) | |
if (warnAbout.nonEmpty) { | |
val blockBuilder = Seq.newBuilder[Block] | |
for (u <- warnAbout.toSeq.sortBy(a.display(_))) { | |
blockBuilder += markupUnused(a, u) | |
} | |
if (warnAbout.size > 1) | |
blockBuilder += Warning(Paragraph(Text(warnAbout.size + " settings were not used."))) | |
blockBuilder += Warning(Paragraph(Text("You can ignore these warnings by adding to the " + silenceUnusedWarning.key + " setting."))) | |
Some(BlockList(blockBuilder.result)) | |
} else { | |
None | |
} | |
} | |
lazy val lintCommand = | |
Command.command("build-lint") { | |
(state: State) => | |
val log = CommandSupport.logger(state) | |
val docOption = markupLint(state) | |
docOption.foreach({ | |
doc => | |
log.warn(renderText(doc, | |
// TODO get actual terminal properties | |
RenderTextOptions(wrapWidth = 100, | |
ansiCodes=true))) | |
}) | |
state | |
} | |
} | |
object Markup { | |
// TODO all the nodes should have id and class fields | |
// that pass through as HTML id and class for CSS purposes. | |
sealed trait Node { | |
def children: Seq[Node] | |
} | |
sealed trait Block extends Node { | |
/** This is a hint for plain text rendering, HTML can usually | |
* do something that looks nice without forcing whitespace. | |
* Should be set to false for "decorator" block types that | |
* will have child block(s) that set it or not. | |
*/ | |
def verticalSpaceAfter: Boolean = true | |
} | |
sealed trait Span extends Node | |
case class Heading(spans: Seq[Span]) extends Block { | |
override def children = spans | |
} | |
case class Paragraph(spans: Seq[Span], | |
override val verticalSpaceAfter: Boolean = true) extends Block { | |
override def children = spans | |
} | |
object Paragraph { | |
def apply(firstSpan: Span, spans: Span*): Paragraph = { | |
Paragraph(firstSpan +: spans.toSeq) | |
} | |
} | |
// in general BlockList(BlockList(a,b),BlockList(c,d)) should be | |
// equivalent to BlockList(a,b,c,d) | |
case class BlockList(blocks: Seq[Block] = Seq.empty, | |
override val verticalSpaceAfter: Boolean = false) extends Block { | |
override def children = blocks | |
def ++(list: BlockList): BlockList = { | |
++(list.blocks) | |
} | |
def ++(list: Seq[Block]): BlockList = { | |
BlockList(blocks ++ list) | |
} | |
def :+(block: Block): BlockList = { | |
block match { | |
case list: BlockList => | |
++(list) | |
case _ => | |
BlockList(blocks :+ block) | |
} | |
} | |
} | |
object BlockList { | |
def apply(firstBlock: Block, blocks: Block*): BlockList = BlockList(firstBlock +: blocks.toSeq) | |
} | |
case class Warning(block: Block) extends Block { | |
override def children = Seq(block) | |
override def verticalSpaceAfter: Boolean = false | |
} | |
case class Error(block: Block) extends Block { | |
override def children = Seq(block) | |
override def verticalSpaceAfter: Boolean = false | |
} | |
// raw text such as output from a markup-unaware command | |
case class NotMarkedUp(text: String) extends Block { | |
override def children = Seq.empty | |
} | |
// same idea as NotMarkedUp but for a different reason | |
case class Preformatted(text: String) extends Block { | |
override def children = Seq.empty | |
} | |
case class Text(text: String) extends Span { | |
override def children = Seq.empty | |
} | |
case class Code(spans: Seq[Span]) extends Span { | |
override def children = spans | |
} | |
object Code { | |
def apply(firstSpan: Span, spans: Span*): Code = Code(firstSpan +: spans.toSeq) | |
} | |
case class Bold(spans: Seq[Span]) extends Span { | |
override def children = Seq.empty | |
} | |
object Bold { | |
def apply(firstSpan: Span, spans: Span*): Bold = Bold(firstSpan +: spans.toSeq) | |
} | |
case class ScopedKeyLink(text: String, unscoped: String, scoped: String, | |
key: ScopedKey[_]) extends Span { | |
override def children = Seq.empty | |
} | |
sealed trait ListStyle | |
case object ListBullet extends ListStyle | |
case object ListNumbered extends ListStyle | |
case class List(style: ListStyle, items: Seq[Paragraph]) extends Block { | |
override def children = items | |
} | |
case class Indented(block: Block, count: Int = 1) extends Block { | |
override def children = Seq(block) | |
override def verticalSpaceAfter: Boolean = false | |
} | |
case class IndentedAfterFirst(block: Block, count: Int = 1) extends Block { | |
override def children = Seq(block) | |
override def verticalSpaceAfter: Boolean = false | |
} | |
case class Table(rows: Seq[Seq[Block]]) extends Block { | |
override def children = rows.flatMap(identity) | |
} | |
def noAnsiLength(s: String): Int = { | |
// this only handles the "SGR" (color/bold) sequences | |
// which are all we use anyway | |
s.replaceAll("\033\\[[0-9]m", "").length | |
} | |
private val indentPrefix = " " | |
private def indent(s: String, count: Int, skipFirstLine: Boolean = false): String = { | |
if (count == 0) { | |
s | |
} else { | |
val indented = | |
if (s.endsWith("\n")) { | |
indent(indentPrefix + | |
s.substring(0, s.length - 1) | |
.replaceAll("\n", "\n" + indentPrefix) + | |
"\n", | |
count - 1) | |
} else { | |
indent(indentPrefix + | |
s | |
.replaceAll("\n", "\n" + indentPrefix), | |
count - 1) | |
} | |
if (skipFirstLine) | |
indented.substring(indentPrefix.length) | |
else | |
indented | |
} | |
} | |
private def wordWrap(s: String, wrapWidth: Int): String = { | |
// this is lame and ASCII-only and lame | |
// get it all on one line, nuking any existing line breaks | |
val words = s.split("\\s") | |
val paraBuilder = new StringBuilder() | |
val lineBuilder = new StringBuilder() | |
for (w <- words) { | |
if (lineBuilder.length > 0 && | |
(lineBuilder.length + noAnsiLength(w)) > wrapWidth) { | |
lineBuilder.append('\n') | |
paraBuilder.append(lineBuilder.toString) | |
lineBuilder.setLength(0) | |
} else if (lineBuilder.length > 0) { | |
lineBuilder.append(' ') | |
} | |
lineBuilder.append(w) | |
} | |
paraBuilder.append(lineBuilder.toString) | |
paraBuilder.toString | |
} | |
private def renderTextSpans(spans: Seq[Span], options: RenderTextOptions): String = { | |
import scala.{ Console => ansi } | |
val sb = new StringBuilder() | |
for (span <- spans) { | |
span match { | |
case Text(text) => | |
sb.append(text) | |
case Code(childSpans) => | |
sb.append(renderTextSpans(childSpans, options)) | |
case Bold(childSpans) => { | |
val needsBold = renderTextSpans(childSpans, options) | |
if (options.ansiCodes) | |
sb.append(ansi.BOLD + needsBold + ansi.RESET) | |
else | |
sb.append(needsBold) | |
} | |
case ScopedKeyLink(text, unscoped, scoped, key) => | |
sb.append(text) | |
} | |
} | |
wordWrap(sb.toString, options.wrapWidth) | |
} | |
private def renderTextList(style: ListStyle, items: Seq[Paragraph], options: RenderTextOptions): String = { | |
val sb = new StringBuilder() | |
for (item <- items) { | |
val indented = indent(renderText(item, options), 1) | |
sb.append(" - " + indented.substring(2)) | |
sb.append("\n") | |
} | |
sb.toString | |
} | |
@tailrec | |
private def appendSplitParas(sb: StringBuilder, | |
columnWidths: IndexedSeq[Int], | |
remaining: Seq[Seq[String]]): Unit = { | |
if (remaining.exists(_.nonEmpty)) { | |
val firstLines = remaining.map(_.head) | |
for ((line, col) <- firstLines.zipWithIndex) { | |
sb.append(line) | |
for (i <- 0 until (columnWidths(col) - noAnsiLength(line))) | |
sb.append(' ') | |
// gap between columns | |
sb.append(indentPrefix) | |
} | |
// remove trailing whitespace on the line | |
while (sb.length > 0 && | |
sb.charAt(sb.length - 1) == ' ') { | |
sb.setLength(sb.length - 1) | |
} | |
// end the line | |
sb.append('\n') | |
// next line | |
appendSplitParas(sb, | |
columnWidths, | |
remaining.map(_.tail)) | |
} | |
} | |
private def renderTextTable(rows: Seq[Seq[Block]], options: RenderTextOptions): String = { | |
val sb = new StringBuilder() | |
val numColumns = rows.maxBy(_.length).length | |
// 12 is "minimum reasonable" | |
val columnWidth = math.max(12, (options.wrapWidth - ((numColumns - 1) * indentPrefix.length)) / numColumns) | |
val rowsAsSplitParas = for (row <- rows) yield { | |
// render each paragraph word-wrapped to max column width | |
val paras = row.map(renderText(_, options.copy(wrapWidth = columnWidth))) | |
// now change each paragraph from a single string to | |
// a Seq[String] one string per line | |
val splitParas = paras.map(_.split("\n").toSeq) | |
// now make all paragraphs on the row have same number | |
// of lines by adding blanks | |
val maxLines = splitParas.map(_.size).max | |
splitParas.map({ | |
lines => | |
lines.padTo(maxLines, "") | |
}) | |
} | |
val columnWidthsPerRow = for (row <- rowsAsSplitParas) yield { | |
row.map({ | |
lines => | |
lines.map(noAnsiLength(_)).max | |
}) | |
} | |
val zeros = Iterator.fill(numColumns)(0).toIndexedSeq | |
val columnWidths = columnWidthsPerRow.foldLeft(zeros)({ | |
(sofar, rowWidths) => | |
sofar.zipAll(rowWidths, 0, 0).map({ | |
widthPair => | |
math.max(widthPair._1, widthPair._2) | |
}) | |
}) | |
for (row <- rowsAsSplitParas) { | |
appendSplitParas(sb, columnWidths, row) | |
} | |
sb.toString | |
} | |
case class RenderTextOptions(wrapWidth: Int = 80, | |
ansiCodes: Boolean = false) | |
private def ensureNewline(s: String): String = { | |
if (s.endsWith("\n")) | |
s | |
else | |
s + "\n" | |
} | |
def renderText(block: Block, options: RenderTextOptions = RenderTextOptions()): String = { | |
val rendered = block match { | |
case BlockList(blocks, _) => { | |
val sb = new StringBuilder() | |
for (b <- blocks) { | |
val text = renderText(b, options) | |
sb.append(text) | |
} | |
sb.toString() | |
} | |
case Indented(child, count) => { | |
val newWrapWidth = options.wrapWidth - indentPrefix.length | |
indent(renderText(child, | |
options.copy(wrapWidth = newWrapWidth)), count) | |
} | |
case IndentedAfterFirst(child, count) => { | |
val newWrapWidth = options.wrapWidth - indentPrefix.length | |
indent(renderText(child, | |
options.copy(wrapWidth = newWrapWidth)), count, | |
skipFirstLine = true) | |
} | |
case Heading(spans) => | |
renderTextSpans(spans, options.copy(wrapWidth=Int.MaxValue)) | |
case Paragraph(spans, _) => | |
renderTextSpans(spans, options) | |
case NotMarkedUp(text) => | |
text | |
case Preformatted(text) => | |
text | |
case List(style, items) => | |
renderTextList(style, items, options) | |
case Table(rows) => | |
renderTextTable(rows, options) | |
case Warning(block) => | |
// handling this properly needs us to take over the | |
// [error] [warn] etc. prefixes from the logger, | |
// or else return some kind of (loglevel, string) | |
// tuple | |
renderText(block, options) | |
case Error(block) => | |
// same issue as Warning | |
renderText(block, options) | |
} | |
// add another newline if we want a blank line after | |
if (block.verticalSpaceAfter) | |
ensureNewline(rendered) + "\n" | |
else | |
ensureNewline(rendered) | |
} | |
private def renderHtmlSpans(spans: Seq[Span], options: RenderHtmlOptions): xml.NodeSeq = { | |
val nodes = xml.NodeSeq.newBuilder | |
for (span <- spans) { | |
span match { | |
case Text(text) => | |
nodes += xml.Text(text) | |
case Code(childSpans) => | |
val codeNodes = xml.NodeSeq.newBuilder | |
for (child <- renderHtmlSpans(childSpans, options)) | |
codeNodes += child | |
nodes += <code>{ codeNodes.result }</code> | |
case Bold(childSpans) => { | |
val boldNodes = xml.NodeSeq.newBuilder | |
for (child <- renderHtmlSpans(childSpans, options)) | |
boldNodes += child | |
nodes += <strong>{ boldNodes.result }</strong> | |
} | |
case ScopedKeyLink(text, unscoped, scoped, key) => | |
if (options.currentKey == Some(unscoped)) { | |
// don't link to self. | |
nodes += <code>{ text }</code> | |
} else { | |
val href = | |
xml.Attribute("href", xml.Text(unscoped + ".html"), | |
xml.Null) | |
nodes += <a><code>{ text }</code></a> % href | |
} | |
} | |
} | |
nodes.result | |
} | |
private def renderHtmlList(style: ListStyle, items: Seq[Paragraph], options: RenderHtmlOptions): xml.NodeSeq = { | |
val liNodes = xml.NodeSeq.newBuilder | |
for (item <- items) { | |
val itemNodes = xml.NodeSeq.newBuilder | |
for (node <- renderHtmlFragment(item, options)) | |
itemNodes += node | |
liNodes += <li> { itemNodes.result } </li> | |
} | |
style match { | |
case ListBullet => | |
<ul> | |
{ liNodes } | |
</ul> | |
case ListNumbered => | |
<ol> | |
{ liNodes } | |
</ol> | |
} | |
} | |
private def renderHtmlTable(rows: Seq[Seq[Block]], options: RenderHtmlOptions): xml.NodeSeq = { | |
val numColumns = rows.maxBy(_.length).length | |
val rowNodes = xml.NodeSeq.newBuilder | |
for (row <- rows) { | |
val paras = row.map(renderHtmlFragment(_, options)) | |
rowNodes += <tr> | |
{ paras.map(nodes => <td>{ nodes }</td>) } | |
</tr> | |
} | |
// TODO set a class and fix this up in css | |
<table border="0"> | |
{ rowNodes.result } | |
</table> | |
} | |
private def renderHtmlIndented(block: Block, count: Int, options: RenderHtmlOptions): xml.NodeSeq = { | |
val childNodes = renderHtmlFragment(block, options) | |
// TODO instead of hardcoding this, set class="" | |
// and deal with it in css | |
val px = 40 * count | |
val attr = xml.Attribute("style", xml.Text("margin-left: " + px + "px;"), xml.Null) | |
<div>{ childNodes }</div> % attr | |
} | |
case class RenderHtmlOptions(currentKey: Option[String]) | |
def renderHtmlFragment(block: Block, options: RenderHtmlOptions): xml.NodeSeq = { | |
val rendered = block match { | |
case BlockList(blocks, _) => { | |
val nodes = xml.NodeSeq.newBuilder | |
for (b <- blocks) { | |
for (node <- renderHtmlFragment(b, options)) | |
nodes += node | |
} | |
nodes.result | |
} | |
case Indented(child, count) => | |
renderHtmlIndented(child, count, options) | |
case IndentedAfterFirst(child, count) => { | |
// we make this the same as Indented for now, | |
// it's really mostly useful for plain text rendering. | |
renderHtmlIndented(child, count, options) | |
} | |
case Heading(spans) => | |
val content = renderHtmlSpans(spans, options) | |
<h2>{ content }</h2> | |
case Paragraph(spans, _) => | |
val content = renderHtmlSpans(spans, options) | |
<p> | |
{ content } | |
</p> | |
case NotMarkedUp(text) => | |
// TODO really don't want <pre> with | |
// monotype font, just want to put in | |
// a forced newline for every newline | |
// and otherwise be a regular paragraph. | |
<pre> | |
{ text } | |
</pre> | |
case Preformatted(text) => | |
<pre> | |
{ text } | |
</pre> | |
case List(style, items) => | |
renderHtmlList(style, items, options) | |
case Table(rows) => | |
renderHtmlTable(rows, options) | |
case Warning(block) => | |
// TODO put this in a nice yellow box with icon | |
// or something like that | |
renderHtmlFragment(block, options) | |
case Error(block) => | |
// TODO put this in a nice red box with icon | |
// or something like that | |
renderHtmlFragment(block, options) | |
} | |
rendered | |
} | |
def wrapHtmlDocument(title: String, fragment: xml.NodeSeq): String = { | |
"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" | |
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | |
""" + | |
<html xmlns="http://www.w3.org/1999/xhtml"> | |
<head> | |
<title>{ title }</title> | |
</head> | |
<body> | |
{ fragment } | |
</body> | |
</html> | |
} | |
def renderHtmlDocument(title: String, block: Block, options: RenderHtmlOptions): String = { | |
wrapHtmlDocument(title, renderHtmlFragment(block, options)) | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment