diff --git a/.gitignore b/.gitignore index b735d504..2d7bcaf6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ project/plugins/project/ # Scala-IDE specific .scala_dependencies .worksheet + +# Ensime +.ensime diff --git a/build.sbt b/build.sbt index b66b7959..b7b72a61 100644 --- a/build.sbt +++ b/build.sbt @@ -1,15 +1,22 @@ -scalaVersion := "2.11.8" - lazy val http4sVersion = "0.15.0-SNAPSHOT" -resolvers += Resolver.sonatypeRepo("snapshots") - -libraryDependencies ++= Seq( - "org.http4s" %% "http4s-dsl" % http4sVersion, - "org.http4s" %% "http4s-blaze-server" % http4sVersion, - "org.http4s" %% "http4s-blaze-client" % http4sVersion, - "org.log4s" %% "log4s" % "1.3.0", - "org.slf4j" % "slf4j-simple" % "1.7.21" +lazy val evaluator = (project in file(".")) + .settings( + name := "evaluator", + scalaVersion := "2.11.8", + resolvers += Resolver.sonatypeRepo("snapshots"), + libraryDependencies ++= Seq( + "org.scala-lang" % "scala-compiler" % scalaVersion.value, + "io.monix" %% "monix" % "2.0-RC8", + "org.http4s" %% "http4s-dsl" % http4sVersion, + "org.http4s" %% "http4s-blaze-server" % http4sVersion, + "org.http4s" %% "http4s-blaze-client" % http4sVersion, + "org.log4s" %% "log4s" % "1.3.0", + "org.slf4j" % "slf4j-simple" % "1.7.21", + "io.get-coursier" %% "coursier" % "1.0.0-M12", + "io.get-coursier" %% "coursier-cache" % "1.0.0-M12", + "org.scalatest" %% "scalatest" % "2.2.4" % "test" + ) ) enablePlugins(JavaAppPackaging) diff --git a/src/main/scala/evaluation.scala b/src/main/scala/evaluation.scala new file mode 100644 index 00000000..f401cf17 --- /dev/null +++ b/src/main/scala/evaluation.scala @@ -0,0 +1,442 @@ +/* + * scala-exercises-evaluator + * Copyright (C) 2015-2016 47 Degrees, LLC. + */ + +package org.scalaexercises.evaluator + + +import scala.language.reflectiveCalls + +import java.io.{ File, InputStream } +import java.net.URLClassLoader +import java.nio.file.Path +import java.util.jar.JarFile +import java.util.concurrent.TimeoutException +import java.security.MessageDigest +import java.math.BigInteger + +import scala.tools.nsc.{ Global, Settings } +import scala.tools.nsc.reporters._ +import scala.tools.nsc.io.{ VirtualDirectory, AbstractFile } +import scala.reflect.internal.util.{ Position, NoPosition, BatchSourceFile, AbstractFileClassLoader } + +import scalaz._; import Scalaz._ +import scala.util.Try +import scala.util.control.NonFatal +import scala.concurrent._ +import scala.concurrent.duration._ +import scalaz.concurrent.Task + +import monix.execution.Scheduler + +import coursier._ + +import org.scalaexercises.evaluator._ + + +class Evaluator(timeout: FiniteDuration = 20.seconds)( + implicit S: Scheduler +) { + type Dependency = (String, String, String) + type Remote = String + + private[this] def convert(errors: (Position, String, String)): (Severity, List[CompilationInfo]) = { + val (pos, msg, severity) = errors + val sev = severity match { + case "ERROR" ⇒ Error + case "WARNING" ⇒ Warning + case _ ⇒ Informational + } + (sev, CompilationInfo(msg, Some(RangePosition(pos.start, pos.point, pos.end))) :: Nil) + } + + def remoteToRepository(remote: Remote): Repository = + MavenRepository(remote) + + def dependencyToModule(dependency: Dependency): coursier.Dependency = + coursier.Dependency( + Module(dependency._1, dependency._2), dependency._3 + ) + + def resolveArtifacts(remotes: Seq[Remote], dependencies: Seq[Dependency]): Task[Resolution] = { + val resolution = Resolution(dependencies.map(dependencyToModule).toSet) + val repositories: Seq[Repository] = remotes.map(remoteToRepository) + val fetch = Fetch.from(repositories, Cache.fetch()) + resolution.process.run(fetch) + } + + def fetchArtifacts(remotes: Seq[Remote], dependencies: Seq[Dependency]): Task[coursier.FileError \/ List[File]] = for { + resolution <- resolveArtifacts(remotes, dependencies) + artifacts <- Task.gatherUnordered( + resolution.artifacts.map(Cache.file(_).run) + ) + } yield artifacts.sequenceU + + def createEval(jars: Seq[File]) = { + new Eval(jars = jars.toList) { + @volatile var errors: Map[Severity, List[CompilationInfo]] = Map.empty + + override lazy val compilerSettings: Settings = new EvalSettings(None){ + if (!jars.isEmpty) { + val newJars = jars.mkString(File.pathSeparator) + classpath.value = newJars + File.pathSeparator + classpath.value + } + } + + override lazy val compilerMessageHandler: Option[Reporter] = Some(new AbstractReporter { + override val settings: Settings = compilerSettings + override def displayPrompt(): Unit = () + override def display(pos: Position, msg: String, severity: this.type#Severity): Unit = { + errors += convert((pos, msg, severity.toString)) + } + override def reset() = { + super.reset() + errors = Map.empty + } + }) + } + } + + private[this] def evaluate[T](code: String, jars: Seq[File]): EvalResult[T] = { + val eval = createEval(jars) + + val result = for { + _ ← Try(eval.check(code)) + result ← Try(eval.execute[T](code, resetState = true, jars = jars)) + } yield result + + val errors = eval.errors.toMap.asInstanceOf[EvalResult.CI] + + result match { + case scala.util.Success(r) ⇒ EvalSuccess[T](errors, r, "") + case scala.util.Failure(t) ⇒ t match { + case e: Eval.CompilerException ⇒ CompilationError(errors) + case NonFatal(e) ⇒ EvalRuntimeError(errors, Option(RuntimeError(e, None))) + case e ⇒ GeneralError(e) + } + } + } + + def eval[T]( + code: String, + remotes: Seq[Remote] = Nil, + dependencies: Seq[Dependency] = Nil + ): Task[EvalResult[T]] = { + for { + allJars <- fetchArtifacts(remotes, dependencies) + result <- allJars match { + case \/-(jars) => Task({ + evaluate(code, jars) + }).timed(timeout).handle({ + case err: TimeoutException => Timeout[T](timeout) + }) + case -\/(fileError) => Task.now(UnresolvedDependency(fileError.describe)) + } + } yield result + } +} + +/** + * Dynamic scala compiler. Lots of (slow) state is created, so it may be advantageous to keep + * around one of these and reuse it. + */ +private class StringCompiler( + lineOffset: Int, + targetDir: Option[File], + output: AbstractFile, + settings: Settings, + messageHandler: Option[Reporter] +) { + + val cache = new scala.collection.mutable.HashMap[String, Class[_]]() + + trait MessageCollector { + val messages: Seq[List[String]] + } + + val reporter = messageHandler getOrElse new AbstractReporter with MessageCollector { + val settings = StringCompiler.this.settings + val messages = new scala.collection.mutable.ListBuffer[List[String]] + + def display(pos: Position, message: String, severity: Severity) { + severity.count += 1 + val severityName = severity match { + case ERROR => "error: " + case WARNING => "warning: " + case _ => "" + } + // the line number is not always available + val lineMessage = + try { + "line " + (pos.line - lineOffset) + } catch { + case _: Throwable => "" + } + messages += (severityName + lineMessage + ": " + message) :: + (if (pos.isDefined) { + pos.inUltimateSource(pos.source).lineContent.stripLineEnd :: + (" " * (pos.column - 1) + "^") :: + Nil + } else { + Nil + }) + } + + def displayPrompt { + // no. + } + + override def reset { + super.reset + messages.clear() + } + } + + val global = new Global(settings, reporter) + + def reset() { + targetDir match { + case None => { + output.asInstanceOf[VirtualDirectory].clear() + } + case Some(t) => { + output.foreach { abstractFile => + if (abstractFile.file == null || abstractFile.file.getName.endsWith(".class")) { + abstractFile.delete() + } + } + } + } + cache.clear() + reporter.reset() + } + + def findClass(className: String, classLoader: ClassLoader): Option[Class[_]] = { + synchronized { + cache.get(className).orElse { + try { + val cls = classLoader.loadClass(className) + cache(className) = cls + Some(cls) + } catch { + case e: ClassNotFoundException => None + } + } + } + } + + /** + * Compile scala code. It can be found using the above class loader. + */ + def apply(code: String) { + // if you're looking for the performance hit, it's 1/2 this line... + val compiler = new global.Run + val sourceFiles = List(new BatchSourceFile("(inline)", code)) + // ...and 1/2 this line: + compiler.compileSources(sourceFiles) + + if (reporter.hasErrors || reporter.WARNING.count > 0) { + val msgs: List[List[String]] = reporter match { + case collector: MessageCollector => + collector.messages.toList + case _ => + List(List(reporter.toString)) + } + throw new Eval.CompilerException(msgs) + } + } + + /** + * Compile a new class, load it, and return it. Thread-safe. + */ + def apply(code: String, className: String, resetState: Boolean = true, classLoader: ClassLoader): Class[_] = { + synchronized { + if (resetState) reset() + + apply(code) + findClass(className, classLoader).get // fixme + } + } +} + + +/** + * Evaluates files, strings, or input streams as Scala code, and returns the result. + * + * If `target` is `None`, the results are compiled to memory (and are therefore ephemeral). If + * `target` is `Some(path)`, the path must point to a directory, and classes will be saved into + * that directory. You can optionally pass a list of JARs to include to the classpath during + * compilation and evaluation. + * + * The flow of evaluation is: + * - wrap code in an `apply` method in a generated class + * - compile the class adding the jars to the classpath + * - contruct an instance of that class + * - return the result of `apply()` + */ +class Eval(target: Option[File] = None, jars: List[File] = Nil) { + private lazy val compilerPath = try { + classPathOfClass("scala.tools.nsc.Interpreter") + } catch { + case e: Throwable => + throw new RuntimeException("Unable to load Scala interpreter from classpath (scala-compiler jar is missing?)", e) + } + + private lazy val libPath = try { + classPathOfClass("scala.AnyVal") + } catch { + case e: Throwable => + throw new RuntimeException("Unable to load scala base object from classpath (scala-library jar is missing?)", e) + } + + // For derived classes to provide an alternate compiler message handler. + protected lazy val compilerMessageHandler: Option[Reporter] = None + + // For derived classes do customize or override the default compiler settings. + protected lazy val compilerSettings: Settings = new EvalSettings(target) + + // Primary encapsulation around native Scala compiler + private[this] lazy val compiler = new StringCompiler( + codeWrapperLineOffset, + target, + compilerOutputDir, + compilerSettings, + compilerMessageHandler + ) + + /** + * Will generate a classname of the form Evaluater__, + * where unique is computed from the jvmID (a random number) + * and a digest of code + */ + def execute[T](code: String, resetState: Boolean, jars: Seq[File]): T = { + val id = uniqueId(code) + val className = "Evaluator__" + id + execute(className, code, resetState, jars) + } + + def execute[T](className: String, code: String, resetState: Boolean, jars: Seq[File]): T = { + val jarUrls = jars.map(jar => new java.net.URL(s"file://${jar.getAbsolutePath}")).toArray + val urlClassLoader = new URLClassLoader(jarUrls , compiler.getClass.getClassLoader) + val classLoader = new AbstractFileClassLoader(compilerOutputDir, urlClassLoader) + + val cls = compiler( + wrapCodeInClass(className, code), className, resetState, classLoader + ) + cls.getConstructor().newInstance().asInstanceOf[() => T].apply().asInstanceOf[T] + } + + /** + * Check if code is Eval-able. + * @throws CompilerException if not Eval-able. + */ + def check(code: String) { + val id = uniqueId(code) + val className = "Evaluator__" + id + val wrappedCode = wrapCodeInClass(className, code) + compiler(wrappedCode) + } + + private[this] def uniqueId(code: String, idOpt: Option[Int] = Some(Eval.jvmId)): String = { + val digest = MessageDigest.getInstance("SHA-1").digest(code.getBytes()) + val sha = new BigInteger(1, digest).toString(16) + idOpt match { + case Some(id) => sha + "_" + id + case _ => sha + } + } + + /* + * Wraps source code in a new class with an apply method. + * NB: If this method is changed, make sure `codeWrapperLineOffset` is correct. + */ + private[this] def wrapCodeInClass(className: String, code: String) = { + s""" +class ${className} extends (() => Any) with java.io.Serializable { + def apply() = { + $code + } +} +""" + } + + /* + * Defines the number of code lines that proceed evaluated code. + * Used to ensure compile error messages report line numbers aligned with user's code. + * NB: If `wrapCodeInClass(String,String)` is changed, make sure this remains correct. + */ + private[this] val codeWrapperLineOffset = 2 + + /* + * For a given FQ classname, trick the resource finder into telling us the containing jar. + */ + private def classPathOfClass(className: String) = { + val resource = className.split('.').mkString("/", "/", ".class") + val path = getClass.getResource(resource).getPath + if (path.indexOf("file:") >= 0) { + val indexOfFile = path.indexOf("file:") + 5 + val indexOfSeparator = path.lastIndexOf('!') + List(path.substring(indexOfFile, indexOfSeparator)) + } else { + require(path.endsWith(resource)) + List(path.substring(0, path.length - resource.length + 1)) + } + } + + /* + * Try to guess our app's classpath. + * This is probably fragile. + */ + lazy val impliedClassPath: List[String] = { + def getClassPath(cl: ClassLoader, acc: List[List[String]] = List.empty): List[List[String]] = { + val cp = cl match { + case urlClassLoader: URLClassLoader => urlClassLoader.getURLs.filter(_.getProtocol == "file"). + map(u => new File(u.toURI).getPath).toList + case _ => Nil + } + cl.getParent match { + case null => (cp :: acc).reverse + case parent => getClassPath(parent, cp :: acc) + } + } + + val classPath = getClassPath(this.getClass.getClassLoader) + val currentClassPath = classPath.head + + // if there's just one thing in the classpath, and it's a jar, assume an executable jar. + currentClassPath ::: (if (currentClassPath.size == 1 && currentClassPath(0).endsWith(".jar")) { + val jarFile = currentClassPath(0) + val relativeRoot = new File(jarFile).getParentFile() + val nestedClassPath = new JarFile(jarFile).getManifest.getMainAttributes.getValue("Class-Path") + if (nestedClassPath eq null) { + Nil + } else { + nestedClassPath.split(" ").map { f => new File(relativeRoot, f).getAbsolutePath }.toList + } + } else { + Nil + }) ::: classPath.tail.flatten + } + + lazy val compilerOutputDir = target match { + case Some(dir) => AbstractFile.getDirectory(dir) + case None => new VirtualDirectory("(memory)", None) + } + + class EvalSettings(targetDir: Option[File]) extends Settings { + nowarnings.value = true // warnings are exceptions, so disable + outputDirs.setSingleOutput(compilerOutputDir) + private[this] val pathList = compilerPath ::: libPath + bootclasspath.value = pathList.mkString(File.pathSeparator) + classpath.value = (pathList ::: impliedClassPath).mkString(File.pathSeparator) + } +} + + +object Eval { + private val jvmId = java.lang.Math.abs(new java.util.Random().nextInt()) + + class CompilerException(val messages: List[List[String]]) extends Exception( + "Compiler exception " + messages.map(_.mkString("\n")).mkString("\n")) +} diff --git a/src/main/scala/types.scala b/src/main/scala/types.scala new file mode 100644 index 00000000..1035d256 --- /dev/null +++ b/src/main/scala/types.scala @@ -0,0 +1,26 @@ +package org.scalaexercises.evaluator + +import scala.concurrent.duration._ + +sealed trait Severity +final case object Informational extends Severity +final case object Warning extends Severity +final case object Error extends Severity + +case class RangePosition(start: Int, point: Int, end: Int) +case class CompilationInfo(message: String, pos: Option[RangePosition]) +case class RuntimeError(val error: Throwable, position: Option[Int]) + +sealed trait EvalResult[+T] + +object EvalResult { + type CI = Map[Severity, List[CompilationInfo]] +} +import EvalResult._ + +case class EvalSuccess[T](complilationInfos: CI, result: T, consoleOutput: String) extends EvalResult[T] +case class Timeout[T](duration: FiniteDuration) extends EvalResult[T] +case class UnresolvedDependency[T](explanation: String) extends EvalResult[T] +case class EvalRuntimeError[T](complilationInfos: CI, runtimeError: Option[RuntimeError]) extends EvalResult[T] +case class CompilationError[T](complilationInfos: CI) extends EvalResult[T] +case class GeneralError[T](stack: Throwable) extends EvalResult[T] diff --git a/src/test/scala/EvaluatorSpec.scala b/src/test/scala/EvaluatorSpec.scala new file mode 100644 index 00000000..c05d7bbf --- /dev/null +++ b/src/test/scala/EvaluatorSpec.scala @@ -0,0 +1,129 @@ +/* + * scala-exercises-evaluator + * Copyright (C) 2015-2016 47 Degrees, LLC. + */ + +package org.scalaexercises.evaluator + +import scala.concurrent.duration._ +import monix.execution.Scheduler +import org.scalatest._ + +class EvaluatorSpec extends FunSpec with Matchers { + implicit val scheduler: Scheduler = Scheduler.io("exercises-spec") + val evaluator = new Evaluator(20 seconds) + + describe("evaluation") { + it("can evaluate simple expressions") { + val result: EvalResult[Int] = evaluator.eval("{ 41 + 1 }").run + + result should matchPattern { + case EvalSuccess(_, 42, _) ⇒ + } + } + + it("fails with a timeout when takes longer than the configured timeout") { + val result: EvalResult[Int] = evaluator.eval("{ while(true) {}; 123 }").run + + result should matchPattern { + case Timeout(_) ⇒ + } + } + + it("can load dependencies for an evaluation") { + val code = """ +import cats._ + +Eval.now(42).value + """ + val remotes = List("https://oss.sonatype.org/content/repositories/releases/") + val dependencies = List( + ("org.typelevel", "cats_2.11", "0.6.0") + ) + + val result: EvalResult[Int] = evaluator.eval( + code, + remotes = remotes, + dependencies = dependencies + ).run + + result should matchPattern { + case EvalSuccess(_, 42, _) => + } + } + + it("can load different versions of a dependency across evaluations") { + val code = """ +import cats._ +Eval.now(42).value + """ + val remotes = List("https://oss.sonatype.org/content/repositories/releases/") + val dependencies1 = List( + ("org.typelevel", "cats_2.11", "0.4.1") + ) + val dependencies2 = List( + ("org.typelevel", "cats_2.11", "0.6.0") + ) + + val result1: EvalResult[Int] = evaluator.eval( + code, + remotes = remotes, + dependencies = dependencies1 + ).run + val result2: EvalResult[Int] = evaluator.eval( + code, + remotes = remotes, + dependencies = dependencies2 + ).run + + result1 should matchPattern { + case EvalSuccess(_, 42, _) => + } + result2 should matchPattern { + case EvalSuccess(_, 42, _) => + } + } + + it("can run code from the exercises content") { + val code = """ +import stdlib._ +Asserts.scalaTestAsserts(true) +""" + val remotes = List("https://oss.sonatype.org/content/repositories/releases/") + val dependencies = List( + ("org.scala-exercises", "exercises-stdlib_2.11", "0.2.0") + ) + + val result: EvalResult[Unit] = evaluator.eval( + code, + remotes = remotes, + dependencies = dependencies + ).run + + result should matchPattern { + case EvalSuccess(_, (), _) => + } + } + + it("captures exceptions when running the exercises content") { + val code = """ +import stdlib._ +Asserts.scalaTestAsserts(false) +""" + val remotes = List("https://oss.sonatype.org/content/repositories/releases/") + val dependencies = List( + ("org.scala-exercises", "exercises-stdlib_2.11", "0.2.0") + ) + + val result: EvalResult[Unit] = evaluator.eval( + code, + remotes = remotes, + dependencies = dependencies + ).run + + result should matchPattern { + case EvalRuntimeError(_, Some(RuntimeError(err: TestFailedException, _))) => + } + } + } +}