diff --git a/src/main/scala/ij_plugins/imagej_launcher/IJConfigFile.scala b/src/main/scala/ij_plugins/imagej_launcher/IJConfigFile.scala new file mode 100644 index 0000000..203d9ca --- /dev/null +++ b/src/main/scala/ij_plugins/imagej_launcher/IJConfigFile.scala @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2000-2023 Jarek Sacha. All Rights Reserved. + * Author's e-mail: jpsacha at gmail.com + */ + +package ij_plugins.imagej_launcher + +import os.Path + +object IJConfigFile: + + private val FileName: String = "ImageJ.cfg" + private val MagicHeader: String = "# ImageJ startup properties" + private val MaxHeapMBKey: String = "maxheap.mb" + private val JvmArgsKey: String = "jvmargs" + private val LegacyModeKey: String = "legacy.mode" + private val AssignmentSymbol: String = "=" + private val CommentSymbol: String = "#" + + /** + * Read `ImageJ.cfg` from a specified directory. + * The configuration file must be in ImageJ2 format (with magic header "# ImageJ startup properties"). + * + * @param dir directory where `ImageJ.cfg` is located + * @param logger logger + * @return option containing the configuration file (Right). + * The option is empty if file is not present in input `dir`. + * An error message is returned if file is present but cannot be read without errors (Left). + */ + def readFromDir(dir: Path)(using logger: Logger): Either[String, Option[IJCConfig]] = + logger.debug(s"Reading ImageJ configuration from directory: $dir") + val path = dir / FileName + logger.debug(s" Looking for $FileName in: '$path'") + readFromFile(path) + + private[imagej_launcher] def readFromFile(ijConfigFile: Path)(using logger: Logger): Either[String, Option[IJCConfig]] = + if os.exists(ijConfigFile) then + val lines = os.read.lines(ijConfigFile) + val cfgE = + lines.headOption match + case Some(line) => + if line.trim.startsWith(MagicHeader) then + logger.debug(s" Parsing $FileName") + for + props <- parseProps(lines) + maxHeapMB <- parseLong(props, MaxHeapMBKey) + jvmArgs <- parseString(props, JvmArgsKey) + legacyMode <- parseBoolean(props, LegacyModeKey) + yield Option(IJCConfig(maxHeapMB, jvmArgs, legacyMode)) + else + Left(s"$FileName is invalid, does not contain header: '$MagicHeader'") + case None => + Left(s"$FileName is empty.") + + // Add context to the error message, if there is one + cfgE.left.map(e => s"Failed to read $FileName. $e") + else + logger.debug(s" $FileName not found: $ijConfigFile") + Right(None) + + private[imagej_launcher] def parseProps(lines: Seq[String]): Either[String, Map[String, String]] = + // Parse each line + val lineResults: Seq[Either[String, (String, String)]] = + lines + .zipWithIndex + .filter { case (line, _) => !line.startsWith(CommentSymbol) } + .map { case (line, lineNumber) => + val index = line.indexOf(AssignmentSymbol) + if index > 0 then + val key = line.substring(0, index).trim + if !key.isBlank then + Right(key -> line.substring(index + 1).trim) + else + Left(s"Error in line $lineNumber: key cannot be empty: '$line'") + else if index == 0 then + Left(s"Error in line $lineNumber: line cannot start with an assignment symbol '$AssignmentSymbol': '$line'") + else + Left(s"Error in line $lineNumber: no assignment symbol '$AssignmentSymbol' present: '$line'") + } + + // Separate lines with errors + val errors = lineResults.collect { case e: Left[?, ?] => e } + if errors.isEmpty then + // Convert key->value pairs to a map + val r = + lineResults + .collect { case e: Right[?, ?] => e } + .map(_.value) + .toMap + Right(r) + else + Left(errors.map(_.value).mkString("\n")) + + private def parseLong(props: Map[String, String], key: String): Either[String, Long] = + parseString(props, key).flatMap: s => + s.toLongOption match + case Some(v) => Right(v) + case None => Left(s"Failed to parse '$s' as an integer.") + + private def parseString(props: Map[String, String], key: String): Either[String, String] = + props.get(key) match + case Some(v) => Right(v) + case None => Left(s"No key named '$key'.") + + private def parseBoolean(props: Map[String, String], key: String): Either[String, Boolean] = + parseString(props, key).flatMap: s => + s.toBooleanOption match + case Some(v) => Right(v) + case None => Left(s"Failed to parse '$s' as a boolean.") + + /** Configuration loaded from `ImageJ.cfg`. */ + case class IJCConfig(maxHeapMB: Long, jvmArgs: String, legacyMode: Boolean) + +end IJConfigFile diff --git a/src/main/scala/ij_plugins/imagej_launcher/Launcher.scala b/src/main/scala/ij_plugins/imagej_launcher/Launcher.scala index 2cb704c..56dde7b 100644 --- a/src/main/scala/ij_plugins/imagej_launcher/Launcher.scala +++ b/src/main/scala/ij_plugins/imagej_launcher/Launcher.scala @@ -13,7 +13,7 @@ import os.Path import java.io.File import java.lang.ProcessBuilder.Redirect -class Launcher(logger: Logger): +class Launcher(using logger: Logger): def run(config: Config): Unit = prepareLaunch(config) match @@ -30,6 +30,7 @@ class Launcher(logger: Logger): for ijDir <- IJDir.locate(config, logger) _ <- Updater.update(ijDir, config.dryRun, logger) + ijConfig <- IJConfigFile.readFromDir(ijDir) launcherJar <- findImageJLauncherJar(ijDir.toIO) javaExe <- locateJavaExecutable(config, ijDir.toIO) systemType <- determineSystemType() @@ -178,6 +179,7 @@ class Launcher(logger: Logger): "plugins", "net.imagej.Main" ) + end buildCommandLine private def launch(command: Seq[String]): Unit = logger.debug("launchImageJ ...") diff --git a/src/main/scala/ij_plugins/imagej_launcher/Main.scala b/src/main/scala/ij_plugins/imagej_launcher/Main.scala index 355f25c..1ab084a 100644 --- a/src/main/scala/ij_plugins/imagej_launcher/Main.scala +++ b/src/main/scala/ij_plugins/imagej_launcher/Main.scala @@ -69,7 +69,7 @@ object Main: private def setupLogger(logLevel: Logger.Level): Unit = logger = new Logger(logLevel) - private def runLauncher(config: Config): Unit = new Launcher(logger).run(config) + private def runLauncher(config: Config): Unit = new Launcher(using logger).run(config) case class Config( logLevel: Logger.Level = Logger.Level.Error, diff --git a/src/test/scala/ij_plugins/imagej_launcher/IJConfigFileTest.scala b/src/test/scala/ij_plugins/imagej_launcher/IJConfigFileTest.scala new file mode 100644 index 0000000..f0260fe --- /dev/null +++ b/src/test/scala/ij_plugins/imagej_launcher/IJConfigFileTest.scala @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2000-2023 Jarek Sacha. All Rights Reserved. + * Author's e-mail: jpsacha at gmail.com + */ + +package ij_plugins.imagej_launcher + +import org.scalatest.flatspec.AnyFlatSpec + +class IJConfigFileTest extends AnyFlatSpec: + + given logger:Logger = new Logger(Logger.Level.All) + + "IJConfigFile" should "read valid config files" in: + val path = os.pwd / "test" / "data" / "config_valid" / "ImageJ.cfg" + + IJConfigFile.readFromFile(path) match + case Right(cfg) => + logger.debug(cfg.toString) + assert(cfg.nonEmpty) + case Left(err) => + logger.debug(s"Unexpected error: $err") + fail() + + it should "fail reading invalid config files" in: + val path = os.pwd / "test" / "data" / "config_invalid" / "ImageJ.cfg" + + IJConfigFile.readFromFile(path) match + case Right(cfg) => + logger.debug(cfg.toString) + fail(s"Expected to return errors when reading config, but read config as: $cfg") + case Left(err) => + logger.debug(s"Expected error\n:$err") diff --git a/test/data/config_invalid/ImageJ.cfg b/test/data/config_invalid/ImageJ.cfg new file mode 100644 index 0000000..9fc162e --- /dev/null +++ b/test/data/config_invalid/ImageJ.cfg @@ -0,0 +1,4 @@ +# ImageJ startup properties +maxheap.mb = 1024m +jvmargs = -XX:+HeapDumpOnOutOfMemoryError -Xincgc +legacy.mode = false \ No newline at end of file diff --git a/test/data/config_valid/ImageJ.cfg b/test/data/config_valid/ImageJ.cfg new file mode 100644 index 0000000..5a40166 --- /dev/null +++ b/test/data/config_valid/ImageJ.cfg @@ -0,0 +1,4 @@ +# ImageJ startup properties +maxheap.mb = 1024 +jvmargs = -XX:+HeapDumpOnOutOfMemoryError -Xincgc +legacy.mode = false \ No newline at end of file