From 345c652a5755fd5c453f4c29a6a99d5e254942e2 Mon Sep 17 00:00:00 2001 From: Jonny Date: Fri, 28 Feb 2025 15:18:24 +0000 Subject: [PATCH 01/18] feat: introduce cats-effect and io --- project.scala | 21 ++++----- src/main/wacc/Main.scala | 85 ++++++++++++++++++------------------ src/test/wacc/examples.scala | 3 +- 3 files changed, 53 insertions(+), 56 deletions(-) diff --git a/project.scala b/project.scala index 4deaa08..6443ad8 100644 --- a/project.scala +++ b/project.scala @@ -5,23 +5,18 @@ //> using dep com.github.j-mie6::parsley::5.0.0-M10 //> using dep com.github.j-mie6::parsley-cats::1.5.0 //> using dep com.lihaoyi::os-lib::0.11.4 +//> using dep org.typelevel::cats-core::2.13.0 +//> using dep org.typelevel::cats-effect::3.5.7 +//> using dep com.monovore::decline::2.5.0 +//> using dep com.monovore::decline-effect::2.5.0 //> using dep com.github.scopt::scopt::4.1.0 //> using test.dep org.scalatest::scalatest::3.2.19 -// these are all sensible defaults to catch annoying issues +// sensible defaults for warnings and compiler checks //> using options -deprecation -unchecked -feature //> using options -Wimplausible-patterns -Wunused:all //> using options -Yexplicit-nulls -Wsafe-init -Xkind-projector:underscores -// these will help ensure you have access to the latest parsley releases -// even before they land on maven proper, or snapshot versions, if necessary. -// just in case they cause problems, however, keep them turned off unless you -// specifically need them. -// using repositories sonatype-s01:releases -// using repositories sonatype-s01:snapshots - -// these are flags used by Scala native: if you aren't using scala-native, then they do nothing -// lto-thin has decent linking times, and release-fast does not too much optimisation. -// using nativeLto thin -// using nativeGc commix -// using nativeMode release-fast +// repositories for pre-release versions if needed +//> using repositories sonatype-s01:releases +//> using repositories sonatype-s01:snapshots diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index fc9fb45..8bf7d3b 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -6,8 +6,12 @@ import parsley.{Failure, Success} import scopt.OParser import java.io.File import java.io.PrintStream +import cats.implicits._ +import cats.effect.unsafe.implicits.global import assemblyIR as asm +import cats.effect.IO +import cats.effect.IOApp case class CliConfig( file: File = new File(".") @@ -36,30 +40,26 @@ val cliParser = { def frontend( contents: String -)(using stdout: PrintStream): Either[microWacc.Program, Int] = { - parser.parse(contents) match { +)(using stdout: PrintStream): IO[microWacc.Program] = { + IO(parser.parse(contents)).flatMap { + case Failure(msg) => IO.raiseError(new RuntimeException(msg)) case Success(prog) => - given errors: mutable.Builder[Error, List[Error]] = List.newBuilder - val (names, funcs) = renamer.rename(prog) - given ctx: typeChecker.TypeCheckerCtx = typeChecker.TypeCheckerCtx(names, funcs, errors) - val typedProg = typeChecker.check(prog) - if (errors.result.nonEmpty) { - given errorContent: String = contents - Right( - errors.result - .map { error => - printError(error) - error match { - case _: Error.InternalError => 201 - case _ => 200 - } - } - .max() - ) - } else Left(typedProg) - case Failure(msg) => - stdout.println(msg) - Right(100) + given errors: mutable.Builder[Error, List[Error]] = List.newBuilder + given errorContent: String = contents + + val (names, funcs) = renamer.rename(prog) + given ctx: typeChecker.TypeCheckerCtx = typeChecker.TypeCheckerCtx(names, funcs, errors) + + val typedProg = typeChecker.check(prog) + + if (errors.result.isEmpty) IO.pure(typedProg) + else { + errors.result.foreach(printError) + IO.raiseError(new RuntimeException("Compilation failed with code: " + errors.result.view.map { + case _: Error.InternalError => 201 + case _ => 200 + }.max)) + } } } @@ -67,26 +67,27 @@ val s = "enter an integer to echo" def backend(typedProg: microWacc.Program): Chain[asm.AsmLine] = asmGenerator.generateAsm(typedProg) + def compile(filename: String, outFile: Option[File] = None)(using stdout: PrintStream = Console.out -): Int = - frontend(os.read(os.Path(filename))) match { - case Left(typedProg) => - val asmFile = outFile.getOrElse(File(filename.stripSuffix(".wacc") + ".s")) - val asm = backend(typedProg) - writer.writeTo(asm, PrintStream(asmFile)) - 0 - case Right(exitCode) => exitCode - } - -def main(args: Array[String]): Unit = - OParser.parse(cliParser, args, CliConfig()) match { - case Some(config) => - System.exit( - compile( - config.file.getAbsolutePath, - outFile = Some(File(".", config.file.getName.stripSuffix(".wacc") + ".s")) +): IO[Int] = + for { + contents <- IO(os.read(os.Path(filename))) + typedProg <- frontend(contents) + _ <- IO { + writer.writeTo( + backend(typedProg), + PrintStream(outFile.getOrElse(File(filename.stripSuffix(".wacc") + ".s"))) ) - ) - case None => } +} yield 0 + +object Main extends IOApp.Simple { + override def run: IO[Unit] = + OParser.parse(cliParser, sys.env.getOrElse("WACC_ARGS", "").split(" "), CliConfig()).traverse_ { config => + compile( + config.file.getAbsolutePath, + outFile = Some(File(".", config.file.getName.stripSuffix(".wacc") + ".s")) + ) + } +} diff --git a/src/test/wacc/examples.scala b/src/test/wacc/examples.scala index 6114afd..4fac462 100644 --- a/src/test/wacc/examples.scala +++ b/src/test/wacc/examples.scala @@ -7,6 +7,7 @@ import java.io.File import sys.process._ import java.io.PrintStream import scala.io.Source +import cats.effect.unsafe.implicits.global class ParallelExamplesSpec extends AnyFlatSpec with BeforeAndAfterAll { val files = @@ -29,7 +30,7 @@ class ParallelExamplesSpec extends AnyFlatSpec with BeforeAndAfterAll { given stdout: PrintStream = PrintStream(File(baseFilename + ".out")) s"$filename" should "be compiled with correct result" in { - val result = compile(filename) + val result = compile(filename).unsafeRunSync() assert(expectedResult.contains(result)) } From cf1028454d69cd0f3087327c4eadb163d8701d54 Mon Sep 17 00:00:00 2001 From: Jonny Date: Fri, 28 Feb 2025 15:20:32 +0000 Subject: [PATCH 02/18] fix: fix frontend tests failing due to expecting error codes instead of runtime exceptions --- src/main/wacc/Main.scala | 57 ++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index 8bf7d3b..71a65bb 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -40,26 +40,29 @@ val cliParser = { def frontend( contents: String -)(using stdout: PrintStream): IO[microWacc.Program] = { - IO(parser.parse(contents)).flatMap { - case Failure(msg) => IO.raiseError(new RuntimeException(msg)) +)(using stdout: PrintStream): IO[Either[Int, microWacc.Program]] = { + IO(parser.parse(contents)).map { + case Failure(msg) => + stdout.println(msg) + Left(100) // Syntax error + case Success(prog) => - given errors: mutable.Builder[Error, List[Error]] = List.newBuilder - given errorContent: String = contents + given errors: mutable.Builder[Error, List[Error]] = List.newBuilder + given errorContent: String = contents - val (names, funcs) = renamer.rename(prog) - given ctx: typeChecker.TypeCheckerCtx = typeChecker.TypeCheckerCtx(names, funcs, errors) + val (names, funcs) = renamer.rename(prog) + given ctx: typeChecker.TypeCheckerCtx = typeChecker.TypeCheckerCtx(names, funcs, errors) - val typedProg = typeChecker.check(prog) + val typedProg = typeChecker.check(prog) - if (errors.result.isEmpty) IO.pure(typedProg) - else { - errors.result.foreach(printError) - IO.raiseError(new RuntimeException("Compilation failed with code: " + errors.result.view.map { - case _: Error.InternalError => 201 - case _ => 200 - }.max)) - } + if (errors.result.isEmpty) Right(typedProg) + else { + errors.result.foreach(printError) + Left(errors.result.view.map { + case _: Error.InternalError => 201 + case _ => 200 + }.max) + } } } @@ -72,15 +75,19 @@ def compile(filename: String, outFile: Option[File] = None)(using stdout: PrintStream = Console.out ): IO[Int] = for { - contents <- IO(os.read(os.Path(filename))) - typedProg <- frontend(contents) - _ <- IO { - writer.writeTo( - backend(typedProg), - PrintStream(outFile.getOrElse(File(filename.stripSuffix(".wacc") + ".s"))) - ) - } -} yield 0 + contents <- IO(os.read(os.Path(filename))) + result <- frontend(contents) + exitCode <- result match { + case Left(code) => IO.pure(code) // Return error code + case Right(typedProg) => + IO { + writer.writeTo( + backend(typedProg), + PrintStream(outFile.getOrElse(File(filename.stripSuffix(".wacc") + ".s"))) + ) + }.as(0) // Compilation succeeded + } + } yield exitCode object Main extends IOApp.Simple { override def run: IO[Unit] = From e54e5ce15158715e211aa37fca6802d5b0d90eb5 Mon Sep 17 00:00:00 2001 From: Jonny Date: Fri, 28 Feb 2025 15:50:53 +0000 Subject: [PATCH 03/18] refactor: style fixes and fold combinator used instead of explicit pattern match --- src/main/wacc/Main.scala | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index 71a65bb..4d3c101 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -6,8 +6,7 @@ import parsley.{Failure, Success} import scopt.OParser import java.io.File import java.io.PrintStream -import cats.implicits._ -import cats.effect.unsafe.implicits.global +import cats.implicits.* import assemblyIR as asm import cats.effect.IO @@ -70,31 +69,31 @@ val s = "enter an integer to echo" def backend(typedProg: microWacc.Program): Chain[asm.AsmLine] = asmGenerator.generateAsm(typedProg) - def compile(filename: String, outFile: Option[File] = None)(using stdout: PrintStream = Console.out ): IO[Int] = for { contents <- IO(os.read(os.Path(filename))) result <- frontend(contents) - exitCode <- result match { - case Left(code) => IO.pure(code) // Return error code - case Right(typedProg) => + exitCode <- result.fold( + IO.pure, // Return error code (handles Left case) + typedProg => IO { writer.writeTo( backend(typedProg), PrintStream(outFile.getOrElse(File(filename.stripSuffix(".wacc") + ".s"))) ) }.as(0) // Compilation succeeded - } + ) } yield exitCode object Main extends IOApp.Simple { override def run: IO[Unit] = - OParser.parse(cliParser, sys.env.getOrElse("WACC_ARGS", "").split(" "), CliConfig()).traverse_ { config => - compile( - config.file.getAbsolutePath, - outFile = Some(File(".", config.file.getName.stripSuffix(".wacc") + ".s")) - ) - } + OParser.parse(cliParser, sys.env.getOrElse("WACC_ARGS", "").split(" "), CliConfig()).traverse_ { + config => + compile( + config.file.getAbsolutePath, + outFile = Some(File(".", config.file.getName.stripSuffix(".wacc") + ".s")) + ) + } } From 1a72decf557820f81281db768603c1a69ff82b90 Mon Sep 17 00:00:00 2001 From: Jonny Date: Fri, 28 Feb 2025 16:24:53 +0000 Subject: [PATCH 04/18] feat: remove unsaferunsync and integrate io in tests instead --- project.scala | 2 + src/test/wacc/examples.scala | 157 ++++++++++++++++++----------------- 2 files changed, 83 insertions(+), 76 deletions(-) diff --git a/project.scala b/project.scala index 6443ad8..86e8a44 100644 --- a/project.scala +++ b/project.scala @@ -11,6 +11,8 @@ //> using dep com.monovore::decline-effect::2.5.0 //> using dep com.github.scopt::scopt::4.1.0 //> using test.dep org.scalatest::scalatest::3.2.19 +//> using dep org.typelevel::cats-effect-testing-scalatest::1.6.0 + // sensible defaults for warnings and compiler checks //> using options -deprecation -unchecked -feature diff --git a/src/test/wacc/examples.scala b/src/test/wacc/examples.scala index 4fac462..cd79161 100644 --- a/src/test/wacc/examples.scala +++ b/src/test/wacc/examples.scala @@ -1,15 +1,19 @@ package wacc import org.scalatest.BeforeAndAfterAll -import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.Inspectors.forEvery +import org.scalatest.matchers.should.Matchers._ +import org.scalatest.freespec.AsyncFreeSpec +import cats.effect.testing.scalatest.AsyncIOSpec import java.io.File import sys.process._ import java.io.PrintStream import scala.io.Source -import cats.effect.unsafe.implicits.global +import cats.effect.IO +import wacc.{compile as compileWacc} + +class ParallelExamplesSpec extends AsyncFreeSpec with AsyncIOSpec with BeforeAndAfterAll { -class ParallelExamplesSpec extends AnyFlatSpec with BeforeAndAfterAll { val files = allWaccFiles("wacc-examples/valid").map { p => (p.toString, List(0)) @@ -24,95 +28,96 @@ class ParallelExamplesSpec extends AnyFlatSpec with BeforeAndAfterAll { (p.toString, List(100, 200)) } - // tests go here forEvery(files) { (filename, expectedResult) => val baseFilename = filename.stripSuffix(".wacc") given stdout: PrintStream = PrintStream(File(baseFilename + ".out")) - s"$filename" should "be compiled with correct result" in { - val result = compile(filename).unsafeRunSync() - assert(expectedResult.contains(result)) - } + s"$filename" - { + "should be compiled with correct result" in { + compileWacc(filename).map { result => + expectedResult should contain(result) + } + } - if (expectedResult == List(0)) it should "run with correct result" in { - if (fileIsDisallowedBackend(filename)) pending + if (expectedResult == List(0)) { + "should run with correct result" in { + if (fileIsDisallowedBackend(filename)) + IO.pure( + succeed + ) // TODO: remove when advanced tests removed. not sure how to "pending" this otherwise + else { + for { + contents <- IO(Source.fromFile(File(filename)).getLines.toList) + inputLine = extractInput(contents) + expectedOutput = extractOutput(contents) + expectedExit = extractExit(contents) - // Retrieve contents to get input and expected output + exit code - val contents = scala.io.Source.fromFile(File(filename)).getLines.toList - val inputLine = - contents - .find(_.matches("^# ?[Ii]nput:.*$")) - .map(_.split(":").last.strip + "\n") - .getOrElse("") - val outputLineIdx = contents.indexWhere(_.matches("^# ?[Oo]utput:.*$")) - val expectedOutput = - if (outputLineIdx == -1) "" - else - contents - .drop(outputLineIdx + 1) - .takeWhile(_.startsWith("#")) - .map(_.stripPrefix("#").stripLeading) - .mkString("\n") + asmFilename = baseFilename + ".s" + execFilename = baseFilename + gccResult <- IO(s"gcc -o $execFilename -z noexecstack $asmFilename".!) - val exitLineIdx = contents.indexWhere(_.matches("^# ?[Ee]xit:.*$")) - val expectedExit = - if (exitLineIdx == -1) 0 - else contents(exitLineIdx + 1).stripPrefix("#").strip.toInt + _ = assert(gccResult == 0) - // Assembly and link using gcc - val asmFilename = baseFilename + ".s" - val execFilename = baseFilename - val gccResult = s"gcc -o $execFilename -z noexecstack $asmFilename".! - assert(gccResult == 0) + stdout <- IO.pure(new StringBuilder) + process <- IO { + s"timeout 5s $execFilename" run ProcessIO( + in = w => { + w.write(inputLine.getBytes) + w.close() + }, + out = Source.fromInputStream(_).addString(stdout), + err = _ => () + ) + } - // Run the executable with the provided input - val stdout = new StringBuilder - val process = s"timeout 5s $execFilename" run ProcessIO( - in = w => { - w.write(inputLine.getBytes) - w.close() - }, - out = Source.fromInputStream(_).addString(stdout), - err = _ => () - ) + exitCode <- IO.pure(process.exitValue) - assert(process.exitValue == expectedExit) - assert( - stdout.toString - .replaceAll("0x[0-9a-f]+", "#addrs#") - .replaceAll("fatal error:.*", "#runtime_error#\u0000") - .takeWhile(_ != '\u0000') - == expectedOutput - ) + } yield { + exitCode shouldBe expectedExit + normalizeOutput(stdout.toString) shouldBe expectedOutput + } + } + } + } } } def allWaccFiles(dir: String): IndexedSeq[os.Path] = val d = java.io.File(dir) - os.walk(os.Path(d.getAbsolutePath)).filter { _.ext == "wacc" } + os.walk(os.Path(d.getAbsolutePath)).filter(_.ext == "wacc") + // TODO: eventually remove this I think def fileIsDisallowedBackend(filename: String): Boolean = Seq( - // format: off - // disable formatting to avoid binPack - "^.*wacc-examples/valid/advanced.*$", - // "^.*wacc-examples/valid/array.*$", - // "^.*wacc-examples/valid/basic/exit.*$", - // "^.*wacc-examples/valid/basic/skip.*$", - // "^.*wacc-examples/valid/expressions.*$", - // "^.*wacc-examples/valid/function/nested_functions.*$", - // "^.*wacc-examples/valid/function/simple_functions.*$", - // "^.*wacc-examples/valid/if.*$", - // "^.*wacc-examples/valid/IO/print.*$", - // "^.*wacc-examples/valid/IO/read.*$", - // "^.*wacc-examples/valid/IO/IOLoop.wacc.*$", - // "^.*wacc-examples/valid/IO/IOSequence.wacc.*$", - // "^.*wacc-examples/valid/pairs.*$", - //"^.*wacc-examples/valid/runtimeErr.*$", - // "^.*wacc-examples/valid/scope.*$", - // "^.*wacc-examples/valid/sequence.*$", - // "^.*wacc-examples/valid/variables.*$", - // "^.*wacc-examples/valid/while.*$", - // format: on - ).find(filename.matches).isDefined + "^.*wacc-examples/valid/advanced.*$" + ).exists(filename.matches) + + private def extractInput(contents: List[String]): String = + contents + .find(_.matches("^# ?[Ii]nput:.*$")) + .map(_.split(":").last.strip + "\n") + .getOrElse("") + + private def extractOutput(contents: List[String]): String = { + val outputLineIdx = contents.indexWhere(_.matches("^# ?[Oo]utput:.*$")) + if (outputLineIdx == -1) "" + else + contents + .drop(outputLineIdx + 1) + .takeWhile(_.startsWith("#")) + .map(_.stripPrefix("#").stripLeading) + .mkString("\n") + } + + private def extractExit(contents: List[String]): Int = { + val exitLineIdx = contents.indexWhere(_.matches("^# ?[Ee]xit:.*$")) + if (exitLineIdx == -1) 0 + else contents(exitLineIdx + 1).stripPrefix("#").strip.toInt + } + + private def normalizeOutput(output: String): String = + output + .replaceAll("0x[0-9a-f]+", "#addrs#") + .replaceAll("fatal error:.*", "#runtime_error#\u0000") + .takeWhile(_ != '\u0000') } From d56be9249a07893769f288484637fd3d65c75ae3 Mon Sep 17 00:00:00 2001 From: Jonny Date: Fri, 28 Feb 2025 18:00:18 +0000 Subject: [PATCH 05/18] refactor: introduce decline to integrate command-line parsing with cats-effect --- project.scala | 1 - src/main/wacc/Main.scala | 65 +++++++++++++++++++++------------------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/project.scala b/project.scala index 86e8a44..b6fb20a 100644 --- a/project.scala +++ b/project.scala @@ -9,7 +9,6 @@ //> using dep org.typelevel::cats-effect::3.5.7 //> using dep com.monovore::decline::2.5.0 //> using dep com.monovore::decline-effect::2.5.0 -//> using dep com.github.scopt::scopt::4.1.0 //> using test.dep org.scalatest::scalatest::3.2.19 //> using dep org.typelevel::cats-effect-testing-scalatest::1.6.0 diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index 4d3c101..ef4575c 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -3,40 +3,40 @@ package wacc import scala.collection.mutable import cats.data.Chain import parsley.{Failure, Success} -import scopt.OParser import java.io.File import java.io.PrintStream import cats.implicits.* -import assemblyIR as asm import cats.effect.IO -import cats.effect.IOApp +import cats.effect.ExitCode + +import com.monovore.decline._ +import com.monovore.decline.effect._ +import com.monovore.decline.Argument + +import assemblyIR as asm + case class CliConfig( file: File = new File(".") ) -val cliBuilder = OParser.builder[CliConfig] -val cliParser = { - import cliBuilder._ - OParser.sequence( - programName("wacc-compiler"), - help('h', "help") - .text("Prints this help message"), - arg[File]("") - .text("Input WACC source file") - .required() - .action((f, c) => c.copy(file = f)) - .validate(f => - if (!f.exists) failure("File does not exist") - else if (!f.isFile) failure("File must be a regular file") - else if (!f.getName.endsWith(".wacc")) - failure("File must have .wacc extension") - else success - ) - ) +given Argument[File] = Argument.from("file") { str => + val file = File(str) + ( + Option.when(file.exists())(file).toValidNel(s"File '${file.getAbsolutePath}' does not exist"), + Option + .when(file.isFile())(file) + .toValidNel(s"File '${file.getAbsolutePath}' must be a regular file"), + Option.when(file.getName.endsWith(".wacc"))(file).toValidNel("File must have .wacc extension") + ).mapN((_, _, _) => file) } +val cliCommand: Command[File] = + Command("wacc-compiler", "Compile WACC programs") { + Opts.argument[File]("file") + } + def frontend( contents: String )(using stdout: PrintStream): IO[Either[Int, microWacc.Program]] = { @@ -87,13 +87,18 @@ def compile(filename: String, outFile: Option[File] = None)(using ) } yield exitCode -object Main extends IOApp.Simple { - override def run: IO[Unit] = - OParser.parse(cliParser, sys.env.getOrElse("WACC_ARGS", "").split(" "), CliConfig()).traverse_ { - config => - compile( - config.file.getAbsolutePath, - outFile = Some(File(".", config.file.getName.stripSuffix(".wacc") + ".s")) - ) +object Main + extends CommandIOApp( + name = "wacc-compiler", + header = "the ultimate wacc compiler", + version = "1.0" + ) { + def main: Opts[IO[ExitCode]] = + Opts.argument[File]("file").map { file => + compile( + file.getAbsolutePath, + outFile = Some(File(".", file.getName.stripSuffix(".wacc") + ".s")) + ).map(ExitCode(_)) // turn the int into exit code for compatibility with commandioapp + // https://ben.kirw.in/decline/effect.html } } From d214723f3560544227ca6578c75f2fc3b8f52000 Mon Sep 17 00:00:00 2001 From: Jonny Date: Fri, 28 Feb 2025 19:36:22 +0000 Subject: [PATCH 06/18] feat: parallelise compilation of multiple files given to cli --- src/main/wacc/Main.scala | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index ef4575c..35479bd 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -16,11 +16,6 @@ import com.monovore.decline.Argument import assemblyIR as asm - -case class CliConfig( - file: File = new File(".") -) - given Argument[File] = Argument.from("file") { str => val file = File(str) ( @@ -94,11 +89,14 @@ object Main version = "1.0" ) { def main: Opts[IO[ExitCode]] = - Opts.argument[File]("file").map { file => - compile( - file.getAbsolutePath, - outFile = Some(File(".", file.getName.stripSuffix(".wacc") + ".s")) - ).map(ExitCode(_)) // turn the int into exit code for compatibility with commandioapp - // https://ben.kirw.in/decline/effect.html + Opts.arguments[File]("files").map { files => + files + .parTraverse_ { file => + compile( + file.getAbsolutePath, + outFile = Some(File(".", file.getName.stripSuffix(".wacc") + ".s")) + ) + } + .as(ExitCode.Success) } } From 667fbf4949bdf553e271797d9933331aa002a9bd Mon Sep 17 00:00:00 2001 From: Jonny Date: Sat, 1 Mar 2025 01:19:50 +0000 Subject: [PATCH 07/18] feat: introduction of logger to eliminate printstreams --- project.scala | 2 ++ src/main/wacc/Main.scala | 42 ++++++++++++++++-------------- src/main/wacc/backend/writer.scala | 23 +++++++++++++--- src/test/wacc/examples.scala | 2 -- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/project.scala b/project.scala index b6fb20a..e4f1c0f 100644 --- a/project.scala +++ b/project.scala @@ -9,6 +9,8 @@ //> using dep org.typelevel::cats-effect::3.5.7 //> using dep com.monovore::decline::2.5.0 //> using dep com.monovore::decline-effect::2.5.0 +//> using dep org.typelevel::log4cats-slf4j::2.7.0 +//> using dep org.slf4j:slf4j-simple:2.0.17 //> using test.dep org.scalatest::scalatest::3.2.19 //> using dep org.typelevel::cats-effect-testing-scalatest::1.6.0 diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index 35479bd..20acd19 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -4,7 +4,6 @@ import scala.collection.mutable import cats.data.Chain import parsley.{Failure, Success} import java.io.File -import java.io.PrintStream import cats.implicits.* import cats.effect.IO @@ -14,6 +13,9 @@ import com.monovore.decline._ import com.monovore.decline.effect._ import com.monovore.decline.Argument +import org.typelevel.log4cats.slf4j.Slf4jLogger +import org.typelevel.log4cats.Logger + import assemblyIR as asm given Argument[File] = Argument.from("file") { str => @@ -32,30 +34,33 @@ val cliCommand: Command[File] = Opts.argument[File]("file") } +given logger: Logger[IO] = Slf4jLogger.getLogger[IO] + def frontend( contents: String -)(using stdout: PrintStream): IO[Either[Int, microWacc.Program]] = { - IO(parser.parse(contents)).map { +): IO[Either[Int, microWacc.Program]] = { + IO(parser.parse(contents)).flatMap { case Failure(msg) => - stdout.println(msg) - Left(100) // Syntax error + logger.error(s"Syntax error: $msg").as(Left(100)) case Success(prog) => given errors: mutable.Builder[Error, List[Error]] = List.newBuilder - given errorContent: String = contents val (names, funcs) = renamer.rename(prog) given ctx: typeChecker.TypeCheckerCtx = typeChecker.TypeCheckerCtx(names, funcs, errors) val typedProg = typeChecker.check(prog) - if (errors.result.isEmpty) Right(typedProg) + if (errors.result.isEmpty) IO.pure(Right(typedProg)) else { - errors.result.foreach(printError) - Left(errors.result.view.map { + val exitCode = errors.result.view.map { case _: Error.InternalError => 201 case _ => 200 - }.max) + }.max + + logger.error(s"Semantic errors:\n${errors.result.mkString("\n")}") *> IO.pure( + Left(exitCode) + ) } } } @@ -64,21 +69,18 @@ val s = "enter an integer to echo" def backend(typedProg: microWacc.Program): Chain[asm.AsmLine] = asmGenerator.generateAsm(typedProg) -def compile(filename: String, outFile: Option[File] = None)(using - stdout: PrintStream = Console.out -): IO[Int] = +def compile(filename: String, outFile: Option[File] = None): IO[Int] = for { contents <- IO(os.read(os.Path(filename))) + _ <- logger.info(s"Compiling file: $filename") result <- frontend(contents) exitCode <- result.fold( - IO.pure, // Return error code (handles Left case) + code => logger.error(s"Compilation failed for $filename\nExit code: $code").as(code), typedProg => - IO { - writer.writeTo( - backend(typedProg), - PrintStream(outFile.getOrElse(File(filename.stripSuffix(".wacc") + ".s"))) - ) - }.as(0) // Compilation succeeded + val outputFile = outFile.getOrElse(File(filename.stripSuffix(".wacc") + ".s")) + writer.writeTo(backend(typedProg), outputFile) *> logger + .info(s"Compilation succeeded: $filename") + .as(0) ) } yield exitCode diff --git a/src/main/wacc/backend/writer.scala b/src/main/wacc/backend/writer.scala index 3c8dcfd..b87ec01 100644 --- a/src/main/wacc/backend/writer.scala +++ b/src/main/wacc/backend/writer.scala @@ -1,12 +1,27 @@ package wacc -import java.io.PrintStream +import cats.effect.Resource +import java.nio.charset.StandardCharsets +import java.io.File +import java.io.BufferedWriter +import java.io.FileWriter import cats.data.Chain +import cats.effect.IO + +import org.typelevel.log4cats.Logger object writer { import assemblyIR._ - def writeTo(asmList: Chain[AsmLine], printStream: PrintStream): Unit = { - asmList.iterator.foreach(printStream.println) - } + def writeTo(asmList: Chain[AsmLine], outputFile: File)(using logger: Logger[IO]): IO[Unit] = + Resource + .fromAutoCloseable { + IO(BufferedWriter(FileWriter(outputFile, StandardCharsets.UTF_8))) + } + .use { writer => + IO { + asmList.iterator.foreach(line => writer.write(line.toString + "\n")) + writer.flush() // TODO: NECESSARY OR NOT? + } *> logger.info(s"Wrote assembly to ${outputFile.getAbsolutePath}") + } } diff --git a/src/test/wacc/examples.scala b/src/test/wacc/examples.scala index cd79161..76e84ed 100644 --- a/src/test/wacc/examples.scala +++ b/src/test/wacc/examples.scala @@ -7,7 +7,6 @@ import org.scalatest.freespec.AsyncFreeSpec import cats.effect.testing.scalatest.AsyncIOSpec import java.io.File import sys.process._ -import java.io.PrintStream import scala.io.Source import cats.effect.IO import wacc.{compile as compileWacc} @@ -30,7 +29,6 @@ class ParallelExamplesSpec extends AsyncFreeSpec with AsyncIOSpec with BeforeAnd forEvery(files) { (filename, expectedResult) => val baseFilename = filename.stripSuffix(".wacc") - given stdout: PrintStream = PrintStream(File(baseFilename + ".out")) s"$filename" - { "should be compiled with correct result" in { From 01b38b14455ad3c48437d2a941c943adf25ac28e Mon Sep 17 00:00:00 2001 From: Jonny Date: Sat, 1 Mar 2025 01:34:05 +0000 Subject: [PATCH 08/18] fix: fix incorrect semantic error logging by refactoring error.scala from frontend --- src/main/wacc/Main.scala | 7 +- src/main/wacc/frontend/Error.scala | 132 ++++++++++++++--------------- 2 files changed, 70 insertions(+), 69 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index 20acd19..ad43479 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -53,14 +53,15 @@ def frontend( if (errors.result.isEmpty) IO.pure(Right(typedProg)) else { + given errorContent: String = contents val exitCode = errors.result.view.map { case _: Error.InternalError => 201 case _ => 200 }.max - logger.error(s"Semantic errors:\n${errors.result.mkString("\n")}") *> IO.pure( - Left(exitCode) - ) + val formattedErrors = errors.result.map(formatError).mkString("\n") + + logger.error(s"Semantic errors:\n$formattedErrors") *> IO.pure(Left(exitCode)) } } } diff --git a/src/main/wacc/frontend/Error.scala b/src/main/wacc/frontend/Error.scala index 9c02a60..b991421 100644 --- a/src/main/wacc/frontend/Error.scala +++ b/src/main/wacc/frontend/Error.scala @@ -2,7 +2,6 @@ package wacc import wacc.ast.Position import wacc.types._ -import java.io.PrintStream /** Error types for semantic errors */ @@ -24,71 +23,72 @@ enum Error { * @param errorContent * Contents of the file to generate code snippets */ -def printError(error: Error)(using errorContent: String, stdout: PrintStream): Unit = { - stdout.println("Semantic error:") - error match { - case Error.DuplicateDeclaration(ident) => - printPosition(ident.pos) - stdout.println(s"Duplicate declaration of identifier ${ident.v}") - highlight(ident.pos, ident.v.length) - case Error.UndeclaredVariable(ident) => - printPosition(ident.pos) - stdout.println(s"Undeclared variable ${ident.v}") - highlight(ident.pos, ident.v.length) - case Error.UndefinedFunction(ident) => - printPosition(ident.pos) - stdout.println(s"Undefined function ${ident.v}") - highlight(ident.pos, ident.v.length) - case Error.FunctionParamsMismatch(id, expected, got, funcType) => - printPosition(id.pos) - stdout.println(s"Function expects $expected parameters, got $got") - stdout.println( - s"(function ${id.v} has type (${funcType.params.mkString(", ")}) -> ${funcType.returnType})" - ) - highlight(id.pos, 1) - case Error.TypeMismatch(pos, expected, got, msg) => - printPosition(pos) - stdout.println(s"Type mismatch: $msg\nExpected: $expected\nGot: $got") - highlight(pos, 1) - case Error.SemanticError(pos, msg) => - printPosition(pos) - stdout.println(msg) - highlight(pos, 1) - case wacc.Error.InternalError(pos, msg) => - printPosition(pos) - stdout.println(s"Internal error: $msg") - highlight(pos, 1) +def formatError(error: Error)(using errorContent: String): String = { + val sb = new StringBuilder() + sb.append("Semantic error:\n") + + /** Function to format the position of an error + * + * @param pos + * Position of the error + */ + def formatPosition(pos: Position): Unit = { + sb.append(s"(line ${pos.line}, column ${pos.column}):\n") } -} - -/** Function to highlight a section of code for an error message - * - * @param pos - * Position of the error - * @param size - * Size(in chars) of section to highlight - * @param errorContent - * Contents of the file to generate code snippets - */ -def highlight(pos: Position, size: Int)(using errorContent: String, stdout: PrintStream): Unit = { - val lines = errorContent.split("\n") - - val preLine = if (pos.line > 1) lines(pos.line - 2) else "" - val midLine = lines(pos.line - 1) - val postLine = if (pos.line < lines.size) lines(pos.line) else "" - val linePointer = " " * (pos.column + 2) + ("^" * (size)) + "\n" - - stdout.println( - s" >$preLine\n >$midLine\n$linePointer >$postLine" - ) -} - -/** Function to print the position of an error - * - * @param pos - * Position of the error - */ -def printPosition(pos: Position)(using stdout: PrintStream): Unit = { - stdout.println(s"(line ${pos.line}, column ${pos.column}):") + /** Function to highlight a section of code for an error message + * + * @param pos + * Position of the error + * @param size + * Size(in chars) of section to highlight + */ + def formatHighlight(pos: Position, size: Int): Unit = { + val lines = errorContent.split("\n") + val preLine = if (pos.line > 1) lines(pos.line - 2) else "" + val midLine = lines(pos.line - 1) + val postLine = if (pos.line < lines.size) lines(pos.line) else "" + val linePointer = " " * (pos.column + 2) + ("^" * (size)) + "\n" + + sb.append( + s" >$preLine\n >$midLine\n$linePointer >$postLine\netscape" + ) + } + + error match { + case Error.DuplicateDeclaration(ident) => + formatPosition(ident.pos) + sb.append(s"Duplicate declaration of identifier ${ident.v}") + formatHighlight(ident.pos, ident.v.length) + case Error.UndeclaredVariable(ident) => + formatPosition(ident.pos) + sb.append(s"Undeclared variable ${ident.v}") + formatHighlight(ident.pos, ident.v.length) + case Error.UndefinedFunction(ident) => + formatPosition(ident.pos) + sb.append(s"Undefined function ${ident.v}") + formatHighlight(ident.pos, ident.v.length) + case Error.FunctionParamsMismatch(id, expected, got, funcType) => + formatPosition(id.pos) + sb.append(s"Function expects $expected parameters, got $got") + sb.append( + s"(function ${id.v} has type (${funcType.params.mkString(", ")}) -> ${funcType.returnType})" + ) + formatHighlight(id.pos, 1) + case Error.TypeMismatch(pos, expected, got, msg) => + formatPosition(pos) + sb.append(s"Type mismatch: $msg\nExpected: $expected\nGot: $got") + formatHighlight(pos, 1) + case Error.SemanticError(pos, msg) => + formatPosition(pos) + sb.append(msg) + formatHighlight(pos, 1) + case wacc.Error.InternalError(pos, msg) => + formatPosition(pos) + sb.append(s"Internal error: $msg") + formatHighlight(pos, 1) + } + + sb.toString() + } From 85a82aabb4fd2e20a058273ba1f035c5480541f1 Mon Sep 17 00:00:00 2001 From: Jonny Date: Sun, 2 Mar 2025 03:12:53 +0000 Subject: [PATCH 09/18] feat: add option flag, greedy compilation of multiple files, and refactor to use paths instead of files --- project.scala | 1 - src/main/wacc/Main.scala | 111 ++++++++++++++++++----------- src/main/wacc/backend/writer.scala | 8 +-- src/test/wacc/examples.scala | 2 +- 4 files changed, 73 insertions(+), 49 deletions(-) diff --git a/project.scala b/project.scala index e4f1c0f..d8b5880 100644 --- a/project.scala +++ b/project.scala @@ -14,7 +14,6 @@ //> using test.dep org.scalatest::scalatest::3.2.19 //> using dep org.typelevel::cats-effect-testing-scalatest::1.6.0 - // sensible defaults for warnings and compiler checks //> using options -deprecation -unchecked -feature //> using options -Wimplausible-patterns -Wunused:all diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index ad43479..f4c290b 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -1,10 +1,11 @@ package wacc import scala.collection.mutable -import cats.data.Chain +import cats.data.{Chain, NonEmptyList} import parsley.{Failure, Success} -import java.io.File -import cats.implicits.* + +import java.nio.file.{Files, Path, Paths} +import cats.syntax.all._ import cats.effect.IO import cats.effect.ExitCode @@ -18,24 +19,30 @@ import org.typelevel.log4cats.Logger import assemblyIR as asm -given Argument[File] = Argument.from("file") { str => - val file = File(str) + +// TODO: IO correctness, --greedy, parallelisable, and probably splitting this file up + + +given Argument[Path] = Argument.from("path") { str => + val path = Path.of(str) ( - Option.when(file.exists())(file).toValidNel(s"File '${file.getAbsolutePath}' does not exist"), - Option - .when(file.isFile())(file) - .toValidNel(s"File '${file.getAbsolutePath}' must be a regular file"), - Option.when(file.getName.endsWith(".wacc"))(file).toValidNel("File must have .wacc extension") - ).mapN((_, _, _) => file) + Either.cond(Files.exists(path), path, s"File '${path.toAbsolutePath}' does not exist"), + Either.cond(Files.isRegularFile(path), path, s"File '${path.toAbsolutePath}' must be a regular file"), + Either.cond(path.toString.endsWith(".wacc"), path, "File must have .wacc extension") + ).mapN((_, _, _) => path).toValidatedNel } -val cliCommand: Command[File] = - Command("wacc-compiler", "Compile WACC programs") { - Opts.argument[File]("file") - } - given logger: Logger[IO] = Slf4jLogger.getLogger[IO] +val logOpt: Opts[Boolean] = + Opts.flag("log", "Enable logging for additional compilation details", short = "l").orFalse + +val outputOpt: Opts[Option[Path]] = + Opts.option[Path]("output", "Specify path for output assembly file(s)").orNone + +val filesOpt: Opts[NonEmptyList[Path]] = Opts.arguments[Path]("files") + + def frontend( contents: String ): IO[Either[Int, microWacc.Program]] = { @@ -51,55 +58,73 @@ def frontend( val typedProg = typeChecker.check(prog) - if (errors.result.isEmpty) IO.pure(Right(typedProg)) + val errResult = errors.result + + if (errResult.isEmpty) IO.pure(Right(typedProg)) else { + // TODO: multiple traversal of error content, should be a foldleft or co given errorContent: String = contents - val exitCode = errors.result.view.map { + val exitCode = errResult.collectFirst { case _: Error.InternalError => 201 - case _ => 200 - }.max + }.getOrElse(200) - val formattedErrors = errors.result.map(formatError).mkString("\n") + val formattedErrors = errResult.map(formatError).mkString("\n") - logger.error(s"Semantic errors:\n$formattedErrors") *> IO.pure(Left(exitCode)) + logger.error(s"Semantic errors:\n$formattedErrors").as(Left(exitCode)) } } } -val s = "enter an integer to echo" def backend(typedProg: microWacc.Program): Chain[asm.AsmLine] = asmGenerator.generateAsm(typedProg) -def compile(filename: String, outFile: Option[File] = None): IO[Int] = +// TODO: filename being String seems unnatural due to Path refactor +// TODO: this function is doing too much should refactor +def compile(filename: String, outputDir: Option[Path], log: Boolean): IO[Int] = + val logAction: String => IO[Unit] = + if (log) logger.info(_) + else (_ => IO.unit) for { - contents <- IO(os.read(os.Path(filename))) - _ <- logger.info(s"Compiling file: $filename") + contents <- IO.delay(os.read(os.Path(filename))) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? + _ <- logAction(s"Compiling file: $filename") result <- frontend(contents) exitCode <- result.fold( code => logger.error(s"Compilation failed for $filename\nExit code: $code").as(code), typedProg => - val outputFile = outFile.getOrElse(File(filename.stripSuffix(".wacc") + ".s")) - writer.writeTo(backend(typedProg), outputFile) *> logger - .info(s"Compilation succeeded: $filename") - .as(0) + val outDir = outputDir.getOrElse(Paths.get(filename).getParent) + IO.delay(Files.createDirectories(outDir)) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? + val outputFile = outDir.resolve(filename.stripSuffix(".wacc") + ".s") + writer.writeTo(backend(typedProg), outputFile) *> // TODO: I dont think we need IO here if we look at the implementation of writer + logAction(s"Compilation succeeded: $filename").as(0) ) } yield exitCode +// TODO: this is sequential, thus should be what occurs when --greedy is passed in +val compileCommand: Opts[IO[ExitCode]] = + (filesOpt, logOpt, outputOpt).mapN{ + (files, log, outDir) => + files + .traverse{ file => + compile( + file.toAbsolutePath.toString, + outDir, + log) + }.map { + exitCodes => + if (exitCodes.exists(_ != 0)) ExitCode.Error // TODO- it should be the first one to exit when parallelised :) + else ExitCode.Success + } + + } + +// TODO: add parallelisable option object Main extends CommandIOApp( - name = "wacc-compiler", - header = "the ultimate wacc compiler", + name = "wacc", + header = "The ultimate WACC compiler", version = "1.0" ) { def main: Opts[IO[ExitCode]] = - Opts.arguments[File]("files").map { files => - files - .parTraverse_ { file => - compile( - file.getAbsolutePath, - outFile = Some(File(".", file.getName.stripSuffix(".wacc") + ".s")) - ) - } - .as(ExitCode.Success) - } -} + compileCommand + +} \ No newline at end of file diff --git a/src/main/wacc/backend/writer.scala b/src/main/wacc/backend/writer.scala index b87ec01..8789835 100644 --- a/src/main/wacc/backend/writer.scala +++ b/src/main/wacc/backend/writer.scala @@ -2,26 +2,26 @@ package wacc import cats.effect.Resource import java.nio.charset.StandardCharsets -import java.io.File import java.io.BufferedWriter import java.io.FileWriter import cats.data.Chain import cats.effect.IO import org.typelevel.log4cats.Logger +import java.nio.file.Path object writer { import assemblyIR._ - def writeTo(asmList: Chain[AsmLine], outputFile: File)(using logger: Logger[IO]): IO[Unit] = + def writeTo(asmList: Chain[AsmLine], outputPath: Path)(using logger: Logger[IO]): IO[Unit] = Resource .fromAutoCloseable { - IO(BufferedWriter(FileWriter(outputFile, StandardCharsets.UTF_8))) + IO(BufferedWriter(FileWriter(outputPath.toFile, StandardCharsets.UTF_8))) } .use { writer => IO { asmList.iterator.foreach(line => writer.write(line.toString + "\n")) writer.flush() // TODO: NECESSARY OR NOT? - } *> logger.info(s"Wrote assembly to ${outputFile.getAbsolutePath}") + } *> logger.info(s"Success: ${outputPath.toAbsolutePath}") } } diff --git a/src/test/wacc/examples.scala b/src/test/wacc/examples.scala index 76e84ed..ca1fe9d 100644 --- a/src/test/wacc/examples.scala +++ b/src/test/wacc/examples.scala @@ -32,7 +32,7 @@ class ParallelExamplesSpec extends AsyncFreeSpec with AsyncIOSpec with BeforeAnd s"$filename" - { "should be compiled with correct result" in { - compileWacc(filename).map { result => + compileWacc(filename, outputDir = None, log = false).map { result => expectedResult should contain(result) } } From 9a5ccea1f610ae583b783a49a6c25bc04161d301 Mon Sep 17 00:00:00 2001 From: Jonny Date: Sun, 2 Mar 2025 03:14:58 +0000 Subject: [PATCH 10/18] style: fix formatting --- src/main/wacc/Main.scala | 57 ++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index f4c290b..7afdd4f 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -19,15 +19,17 @@ import org.typelevel.log4cats.Logger import assemblyIR as asm - // TODO: IO correctness, --greedy, parallelisable, and probably splitting this file up - given Argument[Path] = Argument.from("path") { str => val path = Path.of(str) ( Either.cond(Files.exists(path), path, s"File '${path.toAbsolutePath}' does not exist"), - Either.cond(Files.isRegularFile(path), path, s"File '${path.toAbsolutePath}' must be a regular file"), + Either.cond( + Files.isRegularFile(path), + path, + s"File '${path.toAbsolutePath}' must be a regular file" + ), Either.cond(path.toString.endsWith(".wacc"), path, "File must have .wacc extension") ).mapN((_, _, _) => path).toValidatedNel } @@ -42,7 +44,6 @@ val outputOpt: Opts[Option[Path]] = val filesOpt: Opts[NonEmptyList[Path]] = Opts.arguments[Path]("files") - def frontend( contents: String ): IO[Either[Int, microWacc.Program]] = { @@ -64,9 +65,11 @@ def frontend( else { // TODO: multiple traversal of error content, should be a foldleft or co given errorContent: String = contents - val exitCode = errResult.collectFirst { - case _: Error.InternalError => 201 - }.getOrElse(200) + val exitCode = errResult + .collectFirst { case _: Error.InternalError => + 201 + } + .getOrElse(200) val formattedErrors = errResult.map(formatError).mkString("\n") @@ -85,35 +88,39 @@ def compile(filename: String, outputDir: Option[Path], log: Boolean): IO[Int] = if (log) logger.info(_) else (_ => IO.unit) for { - contents <- IO.delay(os.read(os.Path(filename))) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? + contents <- IO.delay( + os.read(os.Path(filename)) + ) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? _ <- logAction(s"Compiling file: $filename") result <- frontend(contents) exitCode <- result.fold( code => logger.error(s"Compilation failed for $filename\nExit code: $code").as(code), typedProg => val outDir = outputDir.getOrElse(Paths.get(filename).getParent) - IO.delay(Files.createDirectories(outDir)) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? + IO.delay( + Files.createDirectories(outDir) + ) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? val outputFile = outDir.resolve(filename.stripSuffix(".wacc") + ".s") - writer.writeTo(backend(typedProg), outputFile) *> // TODO: I dont think we need IO here if we look at the implementation of writer - logAction(s"Compilation succeeded: $filename").as(0) + writer.writeTo( + backend(typedProg), + outputFile + ) *> // TODO: I dont think we need IO here if we look at the implementation of writer + logAction(s"Compilation succeeded: $filename").as(0) ) } yield exitCode // TODO: this is sequential, thus should be what occurs when --greedy is passed in val compileCommand: Opts[IO[ExitCode]] = - (filesOpt, logOpt, outputOpt).mapN{ - (files, log, outDir) => - files - .traverse{ file => - compile( - file.toAbsolutePath.toString, - outDir, - log) - }.map { - exitCodes => - if (exitCodes.exists(_ != 0)) ExitCode.Error // TODO- it should be the first one to exit when parallelised :) - else ExitCode.Success - } + (filesOpt, logOpt, outputOpt).mapN { (files, log, outDir) => + files + .traverse { file => + compile(file.toAbsolutePath.toString, outDir, log) + } + .map { exitCodes => + if (exitCodes.exists(_ != 0)) + ExitCode.Error // TODO- it should be the first one to exit when parallelised :) + else ExitCode.Success + } } @@ -127,4 +134,4 @@ object Main def main: Opts[IO[ExitCode]] = compileCommand -} \ No newline at end of file +} From abb43b560d439fd5f610fca6db1d37090698783a Mon Sep 17 00:00:00 2001 From: Jonny Date: Sun, 2 Mar 2025 03:26:28 +0000 Subject: [PATCH 11/18] refactor: improve resource safety and structure of writer --- src/main/wacc/backend/writer.scala | 35 +++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/main/wacc/backend/writer.scala b/src/main/wacc/backend/writer.scala index 8789835..cae7110 100644 --- a/src/main/wacc/backend/writer.scala +++ b/src/main/wacc/backend/writer.scala @@ -13,15 +13,30 @@ import java.nio.file.Path object writer { import assemblyIR._ + // TODO: Judging from documentation it seems as though IO.blocking is the correct choice + // But needs checking + + /** Creates a resource safe BufferedWriter */ + private def bufferedWriter(outputPath: Path): Resource[IO, BufferedWriter] = + Resource.make { + IO.blocking(new BufferedWriter(new FileWriter(outputPath.toFile, StandardCharsets.UTF_8))) + } { writer => + IO.blocking(writer.close()) + .handleErrorWith(_ => IO.unit) // TODO: ensures writer is closed even if an error occurs + } + + /** Write line safely into a BufferedWriter */ + private def writeLines(writer: BufferedWriter, lines: Chain[AsmLine]): IO[Unit] = + IO.blocking { + lines.iterator.foreach { line => + writer.write(line.toString) + writer.newLine() + } + } + + /** Main function to write assembly to a file */ def writeTo(asmList: Chain[AsmLine], outputPath: Path)(using logger: Logger[IO]): IO[Unit] = - Resource - .fromAutoCloseable { - IO(BufferedWriter(FileWriter(outputPath.toFile, StandardCharsets.UTF_8))) - } - .use { writer => - IO { - asmList.iterator.foreach(line => writer.write(line.toString + "\n")) - writer.flush() // TODO: NECESSARY OR NOT? - } *> logger.info(s"Success: ${outputPath.toAbsolutePath}") - } + bufferedWriter(outputPath).use { writer => + writeLines(writer, asmList) *> logger.info(s"Success: ${outputPath.toAbsolutePath}") + } } From f66f1ab3ac13864addc7fddbc3b6f165adee758e Mon Sep 17 00:00:00 2001 From: Jonny Date: Sun, 2 Mar 2025 03:48:37 +0000 Subject: [PATCH 12/18] refactor: compile function split up into smaller functions --- src/main/wacc/Main.scala | 81 ++++++++++++++++++++++-------- src/main/wacc/backend/writer.scala | 4 +- src/test/wacc/examples.scala | 3 +- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index 7afdd4f..a47c34c 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -4,7 +4,7 @@ import scala.collection.mutable import cats.data.{Chain, NonEmptyList} import parsley.{Failure, Success} -import java.nio.file.{Files, Path, Paths} +import java.nio.file.{Files, Path} import cats.syntax.all._ import cats.effect.IO @@ -83,38 +83,75 @@ def backend(typedProg: microWacc.Program): Chain[asm.AsmLine] = // TODO: filename being String seems unnatural due to Path refactor // TODO: this function is doing too much should refactor -def compile(filename: String, outputDir: Option[Path], log: Boolean): IO[Int] = +// def compile(filename: String, outputDir: Option[Path], log: Boolean): IO[Int] = +// val logAction: String => IO[Unit] = +// if (log) logger.info(_) +// else (_ => IO.unit) +// for { +// contents <- IO.delay( +// os.read(os.Path(filename)) +// ) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? +// _ <- logAction(s"Compiling file: $filename") +// result <- frontend(contents) +// exitCode <- result.fold( +// code => logger.error(s"Compilation failed for $filename\nExit code: $code").as(code), +// typedProg => +// val outDir = outputDir.getOrElse(Paths.get(filename).getParent) +// IO.delay( +// Files.createDirectories(outDir) +// ) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? +// val outputFile = outDir.resolve(filename.stripSuffix(".wacc") + ".s") +// writer.writeTo( +// backend(typedProg), +// outputFile +// ) *> // TODO: I dont think we need IO here if we look at the implementation of writer +// logAction(s"Compilation succeeded: $filename").as(0) +// ) +// } yield exitCode + +def compile(filePath: Path, outputDir: Option[Path], log: Boolean): IO[Int] = { val logAction: String => IO[Unit] = if (log) logger.info(_) else (_ => IO.unit) + + def readSourceFile: IO[String] = + IO.blocking(os.read(os.Path(filePath))) + + def ensureOutputDir(outDir: Path): IO[Path] = + IO.blocking { + Files.createDirectories(outDir) + outDir + } + + // TODO: path, file , the names are confusing (when Path is the type but we are working with files) + def writeOutputFile(typedProg: microWacc.Program, outputPath: Path): IO[Unit] = + writer.writeTo(backend(typedProg), outputPath) *> + logger.info(s"Success: ${outputPath.toAbsolutePath}") + + def processProgram(contents: String, outDir: Path): IO[Int] = + frontend(contents).flatMap { + case Left(code) => + logger.error(s"Compilation failed for $filePath\nExit code: $code").as(code) + + case Right(typedProg) => + val outputFile = outDir.resolve(filePath.getFileName.toString.stripSuffix(".wacc") + ".s") + writeOutputFile(typedProg, outputFile).as(0) + } + for { - contents <- IO.delay( - os.read(os.Path(filename)) - ) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? - _ <- logAction(s"Compiling file: $filename") - result <- frontend(contents) - exitCode <- result.fold( - code => logger.error(s"Compilation failed for $filename\nExit code: $code").as(code), - typedProg => - val outDir = outputDir.getOrElse(Paths.get(filename).getParent) - IO.delay( - Files.createDirectories(outDir) - ) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? - val outputFile = outDir.resolve(filename.stripSuffix(".wacc") + ".s") - writer.writeTo( - backend(typedProg), - outputFile - ) *> // TODO: I dont think we need IO here if we look at the implementation of writer - logAction(s"Compilation succeeded: $filename").as(0) - ) + contents <- readSourceFile + _ <- logAction(s"Compiling file: ${filePath.toAbsolutePath}") + outDir <- ensureOutputDir(outputDir.getOrElse(filePath.getParent)) + exitCode <- processProgram(contents, outDir) } yield exitCode +} // TODO: this is sequential, thus should be what occurs when --greedy is passed in val compileCommand: Opts[IO[ExitCode]] = (filesOpt, logOpt, outputOpt).mapN { (files, log, outDir) => files .traverse { file => - compile(file.toAbsolutePath.toString, outDir, log) + compile(file.toAbsolutePath, outDir, log) } .map { exitCodes => if (exitCodes.exists(_ != 0)) diff --git a/src/main/wacc/backend/writer.scala b/src/main/wacc/backend/writer.scala index cae7110..a339f55 100644 --- a/src/main/wacc/backend/writer.scala +++ b/src/main/wacc/backend/writer.scala @@ -36,7 +36,7 @@ object writer { /** Main function to write assembly to a file */ def writeTo(asmList: Chain[AsmLine], outputPath: Path)(using logger: Logger[IO]): IO[Unit] = - bufferedWriter(outputPath).use { writer => - writeLines(writer, asmList) *> logger.info(s"Success: ${outputPath.toAbsolutePath}") + bufferedWriter(outputPath).use { + writeLines(_, asmList) } } diff --git a/src/test/wacc/examples.scala b/src/test/wacc/examples.scala index ca1fe9d..11093d6 100644 --- a/src/test/wacc/examples.scala +++ b/src/test/wacc/examples.scala @@ -6,6 +6,7 @@ import org.scalatest.matchers.should.Matchers._ import org.scalatest.freespec.AsyncFreeSpec import cats.effect.testing.scalatest.AsyncIOSpec import java.io.File +import java.nio.file.Path import sys.process._ import scala.io.Source import cats.effect.IO @@ -32,7 +33,7 @@ class ParallelExamplesSpec extends AsyncFreeSpec with AsyncIOSpec with BeforeAnd s"$filename" - { "should be compiled with correct result" in { - compileWacc(filename, outputDir = None, log = false).map { result => + compileWacc(Path.of(filename), outputDir = None, log = false).map { result => expectedResult should contain(result) } } From 473189342b343d3009ce81a0405b14ed0d2fab8f Mon Sep 17 00:00:00 2001 From: Jonny Date: Sun, 2 Mar 2025 03:49:21 +0000 Subject: [PATCH 13/18] refactor: remove commented out code in main.scala --- src/main/wacc/Main.scala | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index a47c34c..750a548 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -81,34 +81,6 @@ def frontend( def backend(typedProg: microWacc.Program): Chain[asm.AsmLine] = asmGenerator.generateAsm(typedProg) -// TODO: filename being String seems unnatural due to Path refactor -// TODO: this function is doing too much should refactor -// def compile(filename: String, outputDir: Option[Path], log: Boolean): IO[Int] = -// val logAction: String => IO[Unit] = -// if (log) logger.info(_) -// else (_ => IO.unit) -// for { -// contents <- IO.delay( -// os.read(os.Path(filename)) -// ) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? -// _ <- logAction(s"Compiling file: $filename") -// result <- frontend(contents) -// exitCode <- result.fold( -// code => logger.error(s"Compilation failed for $filename\nExit code: $code").as(code), -// typedProg => -// val outDir = outputDir.getOrElse(Paths.get(filename).getParent) -// IO.delay( -// Files.createDirectories(outDir) -// ) // TODO: Is IO as a wrapper ok or do we require .delay - also, should it be .blocking? -// val outputFile = outDir.resolve(filename.stripSuffix(".wacc") + ".s") -// writer.writeTo( -// backend(typedProg), -// outputFile -// ) *> // TODO: I dont think we need IO here if we look at the implementation of writer -// logAction(s"Compilation succeeded: $filename").as(0) -// ) -// } yield exitCode - def compile(filePath: Path, outputDir: Option[Path], log: Boolean): IO[Int] = { val logAction: String => IO[Unit] = if (log) logger.info(_) From 19e7ce4c113a17a0b271f181beefb2bd3331cf84 Mon Sep 17 00:00:00 2001 From: Jonny Date: Sun, 2 Mar 2025 06:20:19 +0000 Subject: [PATCH 14/18] fix: fix output flag not reading path passed in --- src/main/wacc/Main.scala | 53 +++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index 750a548..c85e2db 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -18,20 +18,35 @@ import org.typelevel.log4cats.slf4j.Slf4jLogger import org.typelevel.log4cats.Logger import assemblyIR as asm +import java.nio.file.Paths -// TODO: IO correctness, --greedy, parallelisable, and probably splitting this file up +/* +TODO: + 1) IO correctness + 2) --greedy, + 3) parallelised compilation + 4) splitting the file up and nicer refactoring + 5) logging could be removed + 6) errors can be handled more gracefully probably + */ given Argument[Path] = Argument.from("path") { str => - val path = Path.of(str) - ( - Either.cond(Files.exists(path), path, s"File '${path.toAbsolutePath}' does not exist"), - Either.cond( - Files.isRegularFile(path), - path, - s"File '${path.toAbsolutePath}' must be a regular file" - ), - Either.cond(path.toString.endsWith(".wacc"), path, "File must have .wacc extension") - ).mapN((_, _, _) => path).toValidatedNel + val path = + if (str.startsWith("~")) Paths.get(System.getProperty("user.home"), str.drop(1)) // Expand ~ + else Paths.get(str).toAbsolutePath.normalize() // TODO: normalize or not? + + if (path.toString.endsWith(".wacc")) { + ( + Either.cond(Files.exists(path), path, s"File '${path.toAbsolutePath}' does not exist"), + Either.cond( + Files.isRegularFile(path), + path, + s"File '${path.toAbsolutePath}' must be a regular file" + ) + ).mapN((_, _) => path).toValidatedNel + } else { + Right(path).toValidatedNel + } } given logger: Logger[IO] = Slf4jLogger.getLogger[IO] @@ -93,6 +108,15 @@ def compile(filePath: Path, outputDir: Option[Path], log: Boolean): IO[Int] = { IO.blocking { Files.createDirectories(outDir) outDir + }.handleErrorWith { + // TODO: I think this wont occur if a user runs with privileges but this must be checked + // TODO: this will return the ugly stack trace, one could refactor compileCommand to + case _: java.nio.file.AccessDeniedException => + IO.raiseError( + new Exception( + s"Permission denied: Cannot create directory '${outDir.toAbsolutePath}'. Try choosing a different output path or run as root." + ) + ) } // TODO: path, file , the names are confusing (when Path is the type but we are working with files) @@ -123,7 +147,12 @@ val compileCommand: Opts[IO[ExitCode]] = (filesOpt, logOpt, outputOpt).mapN { (files, log, outDir) => files .traverse { file => - compile(file.toAbsolutePath, outDir, log) + compile(file.toAbsolutePath, outDir, log).handleErrorWith { err => + // TODO: probably a more elegant way of doing this + // also, -1 arbitrary + // also - this outputs two messages for some reason + logger.error(err.getMessage) // *> IO.pure(ExitCode(-1)) + } } .map { exitCodes => if (exitCodes.exists(_ != 0)) From f24aecffa3b643f51927ce4ea00e264bb67c9d0d Mon Sep 17 00:00:00 2001 From: Jonny Date: Mon, 3 Mar 2025 02:10:18 +0000 Subject: [PATCH 15/18] fix: remove implicit val causing conflicts with parsing cli arguments --- src/main/wacc/Main.scala | 42 ++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index c85e2db..571103b 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -18,7 +18,7 @@ import org.typelevel.log4cats.slf4j.Slf4jLogger import org.typelevel.log4cats.Logger import assemblyIR as asm -import java.nio.file.Paths +import cats.data.ValidatedNel /* TODO: @@ -30,34 +30,30 @@ TODO: 6) errors can be handled more gracefully probably */ -given Argument[Path] = Argument.from("path") { str => - val path = - if (str.startsWith("~")) Paths.get(System.getProperty("user.home"), str.drop(1)) // Expand ~ - else Paths.get(str).toAbsolutePath.normalize() // TODO: normalize or not? - - if (path.toString.endsWith(".wacc")) { - ( - Either.cond(Files.exists(path), path, s"File '${path.toAbsolutePath}' does not exist"), - Either.cond( - Files.isRegularFile(path), - path, - s"File '${path.toAbsolutePath}' must be a regular file" - ) - ).mapN((_, _) => path).toValidatedNel - } else { - Right(path).toValidatedNel - } -} - given logger: Logger[IO] = Slf4jLogger.getLogger[IO] val logOpt: Opts[Boolean] = Opts.flag("log", "Enable logging for additional compilation details", short = "l").orFalse -val outputOpt: Opts[Option[Path]] = - Opts.option[Path]("output", "Specify path for output assembly file(s)").orNone +def validateFile(path: Path): ValidatedNel[String, Path] = { + (for { + // TODO: redundant 2nd parameter :( + _ <- Either.cond(Files.exists(path), (), s"File '${path}' does not exist") + _ <- Either.cond(Files.isRegularFile(path), (), s"File '${path}' must be a regular file") + _ <- Either.cond(path.toString.endsWith(".wacc"), (), "File must have .wacc extension") + } yield path).toValidatedNel +} -val filesOpt: Opts[NonEmptyList[Path]] = Opts.arguments[Path]("files") +val filesOpt: Opts[NonEmptyList[Path]] = + Opts.arguments[Path]("files").mapValidated { + _.traverse(validateFile) + } + +// TODO: Is intermediate String necessary +val outputOpt: Opts[Option[Path]] = + Opts + .option[Path]("output", metavar = "path", help = "Output directory for compiled files.") + .orNone def frontend( contents: String From 94ee489fafdf6286e4e499a021fd72be89096b0a Mon Sep 17 00:00:00 2001 From: Jonny Date: Mon, 3 Mar 2025 02:58:04 +0000 Subject: [PATCH 16/18] feat: greedy cli argument implemented, parallel compilation now by default, but no fail fast behaviour --- src/main/wacc/Main.scala | 70 ++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index 571103b..33c4fef 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -23,11 +23,10 @@ import cats.data.ValidatedNel /* TODO: 1) IO correctness - 2) --greedy, - 3) parallelised compilation - 4) splitting the file up and nicer refactoring - 5) logging could be removed - 6) errors can be handled more gracefully probably + 2) Errors can be handled more gracefully - currently, parallelised compilation is not fail fast as far as I am aware + 3) splitting the file up and nicer refactoring + 4) logging could be removed + 5) general cleanup and comments (things like replacing home/ with ~ , and names of parameters and args, descriptions etc) */ given logger: Logger[IO] = Slf4jLogger.getLogger[IO] @@ -49,12 +48,14 @@ val filesOpt: Opts[NonEmptyList[Path]] = _.traverse(validateFile) } -// TODO: Is intermediate String necessary val outputOpt: Opts[Option[Path]] = Opts .option[Path]("output", metavar = "path", help = "Output directory for compiled files.") .orNone +val greedyOpt: Opts[Boolean] = + Opts.flag("greedy", "Compile WACC files sequentially instead of parallelly", short = "g").orFalse + def frontend( contents: String ): IO[Either[Int, microWacc.Program]] = { @@ -138,27 +139,43 @@ def compile(filePath: Path, outputDir: Option[Path], log: Boolean): IO[Int] = { } yield exitCode } -// TODO: this is sequential, thus should be what occurs when --greedy is passed in -val compileCommand: Opts[IO[ExitCode]] = - (filesOpt, logOpt, outputOpt).mapN { (files, log, outDir) => - files - .traverse { file => - compile(file.toAbsolutePath, outDir, log).handleErrorWith { err => - // TODO: probably a more elegant way of doing this - // also, -1 arbitrary - // also - this outputs two messages for some reason - logger.error(err.getMessage) // *> IO.pure(ExitCode(-1)) - } +// TODO: Remove duplicate code between compileCommandSequential and compileCommandParallel +def compileCommandSequential( + files: NonEmptyList[Path], + log: Boolean, + outDir: Option[Path] +): IO[ExitCode] = + files + .traverse { file => + compile(file.toAbsolutePath, outDir, log).handleErrorWith { err => + // TODO: probably a more elegant way of doing this + // also, -1 arbitrary + // also - this outputs two messages for some reason + logger.error(err.getMessage) // *> IO.raiseError(err) } - .map { exitCodes => - if (exitCodes.exists(_ != 0)) - ExitCode.Error // TODO- it should be the first one to exit when parallelised :) - else ExitCode.Success + } + .map { exitCodes => + if (exitCodes.exists(_ != 0)) ExitCode.Error else ExitCode.Success + } + +def compileCommandParallel( + files: NonEmptyList[Path], + log: Boolean, + outDir: Option[Path] +): IO[ExitCode] = + files + .parTraverse { file => + compile(file.toAbsolutePath, outDir, log).handleErrorWith { err => + // TODO: probably a more elegant way of doing this + // also, -1 arbitrary + // also - this outputs two messages for some reason + logger.error(err.getMessage) // *> IO.raiseError(err) } + } + .map { exitCodes => + if (exitCodes.exists(_ != 0)) ExitCode.Error else ExitCode.Success + } - } - -// TODO: add parallelisable option object Main extends CommandIOApp( name = "wacc", @@ -166,6 +183,9 @@ object Main version = "1.0" ) { def main: Opts[IO[ExitCode]] = - compileCommand + (greedyOpt, filesOpt, logOpt, outputOpt).mapN { (greedy, files, log, outDir) => + if (greedy) compileCommandSequential(files, log, outDir) + else compileCommandParallel(files, log, outDir) + } } From 96ba81e24aef2eadfd663fcea6adcea502c19a7b Mon Sep 17 00:00:00 2001 From: Gleb Koval Date: Sun, 9 Mar 2025 23:37:04 +0000 Subject: [PATCH 17/18] refactor: consistent error handling in Main.scala --- project.scala | 2 - src/main/wacc/Main.scala | 126 +++++++++++------------------ src/main/wacc/frontend/Error.scala | 23 +++++- src/main/wacc/frontend/ast.scala | 1 - 4 files changed, 69 insertions(+), 83 deletions(-) diff --git a/project.scala b/project.scala index d8b5880..8edf035 100644 --- a/project.scala +++ b/project.scala @@ -20,5 +20,3 @@ //> using options -Yexplicit-nulls -Wsafe-init -Xkind-projector:underscores // repositories for pre-release versions if needed -//> using repositories sonatype-s01:releases -//> using repositories sonatype-s01:snapshots diff --git a/src/main/wacc/Main.scala b/src/main/wacc/Main.scala index 33c4fef..d964657 100644 --- a/src/main/wacc/Main.scala +++ b/src/main/wacc/Main.scala @@ -12,7 +12,6 @@ import cats.effect.ExitCode import com.monovore.decline._ import com.monovore.decline.effect._ -import com.monovore.decline.Argument import org.typelevel.log4cats.slf4j.Slf4jLogger import org.typelevel.log4cats.Logger @@ -29,6 +28,9 @@ TODO: 5) general cleanup and comments (things like replacing home/ with ~ , and names of parameters and args, descriptions etc) */ +private val SUCCESS = ExitCode.Success.code +private val ERROR = ExitCode.Error.code + given logger: Logger[IO] = Slf4jLogger.getLogger[IO] val logOpt: Opts[Boolean] = @@ -51,49 +53,46 @@ val filesOpt: Opts[NonEmptyList[Path]] = val outputOpt: Opts[Option[Path]] = Opts .option[Path]("output", metavar = "path", help = "Output directory for compiled files.") + .validate("Must have permissions to create & access the output path") { path => + try { + Files.createDirectories(path) + true + } catch { + case e: java.nio.file.AccessDeniedException => + false + } + } + .validate("Output path must be a directory") { path => + Files.isDirectory(path) + } .orNone -val greedyOpt: Opts[Boolean] = - Opts.flag("greedy", "Compile WACC files sequentially instead of parallelly", short = "g").orFalse - def frontend( contents: String -): IO[Either[Int, microWacc.Program]] = { - IO(parser.parse(contents)).flatMap { - case Failure(msg) => - logger.error(s"Syntax error: $msg").as(Left(100)) - +): Either[NonEmptyList[Error], microWacc.Program] = + parser.parse(contents) match { + case Failure(msg) => Left(NonEmptyList.one(Error.SyntaxError(msg))) case Success(prog) => given errors: mutable.Builder[Error, List[Error]] = List.newBuilder val (names, funcs) = renamer.rename(prog) given ctx: typeChecker.TypeCheckerCtx = typeChecker.TypeCheckerCtx(names, funcs, errors) - val typedProg = typeChecker.check(prog) - val errResult = errors.result - - if (errResult.isEmpty) IO.pure(Right(typedProg)) - else { - // TODO: multiple traversal of error content, should be a foldleft or co - given errorContent: String = contents - val exitCode = errResult - .collectFirst { case _: Error.InternalError => - 201 - } - .getOrElse(200) - - val formattedErrors = errResult.map(formatError).mkString("\n") - - logger.error(s"Semantic errors:\n$formattedErrors").as(Left(exitCode)) + NonEmptyList.fromList(errors.result) match { + case Some(errors) => Left(errors) + case None => Right(typedProg) } } -} def backend(typedProg: microWacc.Program): Chain[asm.AsmLine] = asmGenerator.generateAsm(typedProg) -def compile(filePath: Path, outputDir: Option[Path], log: Boolean): IO[Int] = { +def compile( + filePath: Path, + outputDir: Option[Path], + log: Boolean +): IO[Int] = { val logAction: String => IO[Unit] = if (log) logger.info(_) else (_ => IO.unit) @@ -101,79 +100,49 @@ def compile(filePath: Path, outputDir: Option[Path], log: Boolean): IO[Int] = { def readSourceFile: IO[String] = IO.blocking(os.read(os.Path(filePath))) - def ensureOutputDir(outDir: Path): IO[Path] = - IO.blocking { - Files.createDirectories(outDir) - outDir - }.handleErrorWith { - // TODO: I think this wont occur if a user runs with privileges but this must be checked - // TODO: this will return the ugly stack trace, one could refactor compileCommand to - case _: java.nio.file.AccessDeniedException => - IO.raiseError( - new Exception( - s"Permission denied: Cannot create directory '${outDir.toAbsolutePath}'. Try choosing a different output path or run as root." - ) - ) - } - // TODO: path, file , the names are confusing (when Path is the type but we are working with files) def writeOutputFile(typedProg: microWacc.Program, outputPath: Path): IO[Unit] = writer.writeTo(backend(typedProg), outputPath) *> logger.info(s"Success: ${outputPath.toAbsolutePath}") def processProgram(contents: String, outDir: Path): IO[Int] = - frontend(contents).flatMap { - case Left(code) => - logger.error(s"Compilation failed for $filePath\nExit code: $code").as(code) + frontend(contents) match { + case Left(errors) => + val code = errors.map(err => err.exitCode).toList.min + given errorContent: String = contents + val errorMsg = errors.map(formatError).toIterable.mkString("\n") + for { + _ <- logAction(s"Compilation failed for $filePath\nExit code: $code") + _ <- IO.blocking( + // Explicit println since we want this to always show without logger thread info e.t.c. + println(s"Compilation failed for ${filePath.toAbsolutePath}:\n$errorMsg") + ) + } yield code case Right(typedProg) => val outputFile = outDir.resolve(filePath.getFileName.toString.stripSuffix(".wacc") + ".s") - writeOutputFile(typedProg, outputFile).as(0) + writeOutputFile(typedProg, outputFile).as(SUCCESS) } for { contents <- readSourceFile _ <- logAction(s"Compiling file: ${filePath.toAbsolutePath}") - outDir <- ensureOutputDir(outputDir.getOrElse(filePath.getParent)) - exitCode <- processProgram(contents, outDir) + exitCode <- processProgram(contents, outputDir.getOrElse(filePath.getParent)) } yield exitCode } -// TODO: Remove duplicate code between compileCommandSequential and compileCommandParallel -def compileCommandSequential( - files: NonEmptyList[Path], - log: Boolean, - outDir: Option[Path] -): IO[ExitCode] = - files - .traverse { file => - compile(file.toAbsolutePath, outDir, log).handleErrorWith { err => - // TODO: probably a more elegant way of doing this - // also, -1 arbitrary - // also - this outputs two messages for some reason - logger.error(err.getMessage) // *> IO.raiseError(err) - } - } - .map { exitCodes => - if (exitCodes.exists(_ != 0)) ExitCode.Error else ExitCode.Success - } - def compileCommandParallel( files: NonEmptyList[Path], log: Boolean, outDir: Option[Path] ): IO[ExitCode] = files - .parTraverse { file => - compile(file.toAbsolutePath, outDir, log).handleErrorWith { err => - // TODO: probably a more elegant way of doing this - // also, -1 arbitrary - // also - this outputs two messages for some reason - logger.error(err.getMessage) // *> IO.raiseError(err) - } - } + .parTraverse { file => compile(file.toAbsolutePath, outDir, log) } .map { exitCodes => - if (exitCodes.exists(_ != 0)) ExitCode.Error else ExitCode.Success + exitCodes.filter(_ != 0) match { + case Nil => ExitCode.Success + case errorCodes => ExitCode(errorCodes.min) + } } object Main @@ -183,9 +152,8 @@ object Main version = "1.0" ) { def main: Opts[IO[ExitCode]] = - (greedyOpt, filesOpt, logOpt, outputOpt).mapN { (greedy, files, log, outDir) => - if (greedy) compileCommandSequential(files, log, outDir) - else compileCommandParallel(files, log, outDir) + (filesOpt, logOpt, outputOpt).mapN { (files, log, outDir) => + compileCommandParallel(files, log, outDir) } } diff --git a/src/main/wacc/frontend/Error.scala b/src/main/wacc/frontend/Error.scala index b991421..e515494 100644 --- a/src/main/wacc/frontend/Error.scala +++ b/src/main/wacc/frontend/Error.scala @@ -3,6 +3,9 @@ package wacc import wacc.ast.Position import wacc.types._ +private val SYNTAX_ERROR = 100 +private val SEMANTIC_ERROR = 200 + /** Error types for semantic errors */ enum Error { @@ -14,6 +17,15 @@ enum Error { case SemanticError(pos: Position, msg: String) case TypeMismatch(pos: Position, expected: SemType, got: SemType, msg: String) case InternalError(pos: Position, msg: String) + + case SyntaxError(msg: String) +} + +extension (e: Error) { + def exitCode: Int = e match { + case Error.SyntaxError(_) => SYNTAX_ERROR + case _ => SEMANTIC_ERROR + } } /** Function to handle printing the details of a given semantic error @@ -25,7 +37,6 @@ enum Error { */ def formatError(error: Error)(using errorContent: String): String = { val sb = new StringBuilder() - sb.append("Semantic error:\n") /** Function to format the position of an error * @@ -55,6 +66,13 @@ def formatError(error: Error)(using errorContent: String): String = { ) } + error match { + case Error.SyntaxError(_) => + sb.append("Syntax error:\n") + case _ => + sb.append("Semantic error:\n") + } + error match { case Error.DuplicateDeclaration(ident) => formatPosition(ident.pos) @@ -87,6 +105,9 @@ def formatError(error: Error)(using errorContent: String): String = { formatPosition(pos) sb.append(s"Internal error: $msg") formatHighlight(pos, 1) + case Error.SyntaxError(msg) => + sb.append(msg) + sb.append("\n") } sb.toString() diff --git a/src/main/wacc/frontend/ast.scala b/src/main/wacc/frontend/ast.scala index ac0e585..9b14b13 100644 --- a/src/main/wacc/frontend/ast.scala +++ b/src/main/wacc/frontend/ast.scala @@ -4,7 +4,6 @@ import parsley.Parsley import parsley.generic.ErrorBridge import parsley.ap._ import parsley.position._ -import parsley.syntax.zipped._ import cats.data.NonEmptyList object ast { From 0d8be53ae45c1db87cc18919a3a3cfa95dd57a2e Mon Sep 17 00:00:00 2001 From: Gleb Koval Date: Wed, 12 Mar 2025 23:34:39 +0000 Subject: [PATCH 18/18] fix: set output to `.` for labts compiler --- compile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compile b/compile index 5505e42..444f591 100755 --- a/compile +++ b/compile @@ -4,6 +4,6 @@ # but do *not* change its name. # feel free to adjust to suit the specific internal flags of your compiler -./wacc-compiler "$@" +./wacc-compiler --output . "$@" exit $?