summaryrefslogtreecommitdiff
path: root/support/arara/source/src/main/kotlin/org/islandoftex/arara
diff options
context:
space:
mode:
Diffstat (limited to 'support/arara/source/src/main/kotlin/org/islandoftex/arara')
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/Arara.kt115
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/CLI.kt171
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/AraraSpec.kt90
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/Configuration.kt101
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/ConfigurationUtils.kt157
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/LocalConfiguration.kt85
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/Database.kt24
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/DatabaseUtils.kt97
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/FileHandlingUtils.kt319
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/FileSearchingUtils.kt158
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/Language.kt69
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/LanguageController.kt59
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/Messages.kt106
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/model/AraraException.kt50
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Evaluator.kt122
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Extractor.kt50
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/model/FileType.kt110
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Interpreter.kt375
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Session.kt119
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Argument.kt40
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Command.kt57
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Conditional.kt67
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Directive.kt32
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/DirectiveAssembler.kt55
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/DirectiveUtils.kt312
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Rule.kt48
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/RuleCommand.kt33
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/RuleUtils.kt140
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/ClassLoadingUtils.kt121
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/CommonUtils.kt422
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/DisplayUtils.kt415
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/Extensions.kt68
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/InterpreterUtils.kt164
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/LoggingUtils.kt70
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/MessageUtils.kt266
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/SystemCallUtils.kt105
-rw-r--r--support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/TeeOutputStream.kt66
37 files changed, 4858 insertions, 0 deletions
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/Arara.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/Arara.kt
new file mode 100644
index 0000000000..2b4efbf8db
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/Arara.kt
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara
+
+import com.github.ajalt.clikt.parameters.options.versionOption
+import com.uchuhimo.konf.Config
+import java.time.LocalDate
+import kotlin.time.ExperimentalTime
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.configuration.Configuration
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.model.Extractor
+import org.islandoftex.arara.model.Interpreter
+import org.islandoftex.arara.ruleset.DirectiveUtils
+import org.islandoftex.arara.utils.DisplayUtils
+
+/**
+ * arara's main entry point
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 5.0
+ */
+object Arara {
+ // TODO: watch config files
+ val baseconfig = Config { addSpec(AraraSpec) }
+ .from.env()
+ .from.systemProperties()
+ var config = baseconfig.withLayer("initial")
+
+ /**
+ * Main method. This is the application entry point.
+ * @param args A string array containing all command line arguments.
+ */
+ @ExperimentalTime
+ @JvmStatic
+ fun main(args: Array<String>) {
+ // print the arara logo in the terminal; I just
+ // hope people use this tool in a good terminal with
+ // fixed-width fonts, otherwise the logo will be messed
+ DisplayUtils.printLogo()
+
+ val version = config[AraraSpec.Application.version]
+ CLI().versionOption(version, names = setOf("-V", "--version"),
+ message = {
+ "arara $version\n" +
+ "Copyright (c) ${LocalDate.now().year}, Island of TeX\n" +
+ LanguageController.getMessage(Messages
+ .INFO_PARSER_NOTES) + "\n\n" +
+ "New features in version $version:\n" +
+ Arara::class.java
+ .getResource("/org/islandoftex/arara/configuration/release-notes")
+ .readText()
+ })
+ .main(args)
+ }
+
+ @ExperimentalTime
+ fun run() {
+ try {
+ // first of all, let's try to load a potential
+ // configuration file located at the current
+ // user's home directory; if there's a bad
+ // configuration file, arara will panic and
+ // end the execution
+ Configuration.load()
+
+ // let's print the current file information; it is a
+ // basic display, just the file name, the size properly
+ // formatted as a human readable format, and the last
+ // modification date; also, in this point, the logging
+ // feature starts to collect data (of course, if enabled
+ // either through the configuration file or manually
+ // in the command line)
+ DisplayUtils.printFileInformation()
+
+ // time to read the file and try to extract the directives;
+ // extract() brings us a list of directives properly parsed
+ // and almost ready to be handled; note that no directives
+ // in the provided file will raise an exception; this is
+ // by design and I opted to not include a default fallback
+ // (although it wouldn't be so difficult to write one,
+ // I decided not to take the risk)
+ val extracted = Extractor.extract(config[AraraSpec.Execution
+ .reference])
+
+ // it is time to validate the directives (for example, we have
+ // a couple of keywords that cannot be used as directive
+ // parameters); another interesting feature of the validate()
+ // method is to replicate a directive that has the 'files'
+ // keyword on it, since it's the whole point of having 'files'
+ // in the first place; if you check the log file, you will see
+ // that the list of extracted directives might differ from
+ // the final list of directives to be effectively processed
+ // by arara
+ val directives = DirectiveUtils.process(extracted)
+
+ // time to shine, now the interpreter class will interpret
+ // one directive at a time, get the corresponding rule,
+ // set the parameters, evaluate it, get the tasks, run them,
+ // evaluate the result and print the status; note that
+ // arara, from this version on, will try to evaluate things
+ // progressively, so in case of an error, the previous tasks
+ // were already processed and potentially executed
+ Interpreter(directives).execute()
+ } catch (exception: AraraException) {
+ // something bad just happened, so arara will print the proper
+ // exception and provide details on it, if available; the idea
+ // here is to propagate an exception throughout the whole
+ // application and catch it here instead of a local treatment
+ DisplayUtils.printException(exception)
+ }
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/CLI.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/CLI.kt
new file mode 100644
index 0000000000..9de0f1bd3f
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/CLI.kt
@@ -0,0 +1,171 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.parameters.arguments.argument
+import com.github.ajalt.clikt.parameters.arguments.multiple
+import com.github.ajalt.clikt.parameters.options.default
+import com.github.ajalt.clikt.parameters.options.flag
+import com.github.ajalt.clikt.parameters.options.option
+import com.github.ajalt.clikt.parameters.types.int
+import com.github.ajalt.clikt.parameters.types.path
+import com.github.ajalt.clikt.parameters.types.restrictTo
+import kotlin.system.exitProcess
+import kotlin.time.ClockMark
+import kotlin.time.ExperimentalTime
+import kotlin.time.MonoClock
+import kotlin.time.milliseconds
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.filehandling.FileSearchingUtils
+import org.islandoftex.arara.localization.Language
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.model.Session
+import org.islandoftex.arara.utils.CommonUtils
+import org.islandoftex.arara.utils.DisplayUtils
+import org.islandoftex.arara.utils.LoggingUtils
+
+/**
+ * arara's command line interface
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 5.0
+ */
+class CLI : CliktCommand(name = "arara", printHelpOnEmptyArgs = true) {
+ private val log by option("-l", "--log",
+ help = "Generate a log output")
+ .flag(default = AraraSpec.Execution.logging.default)
+ private val verbose by option("-v", "--verbose",
+ help = "Print the command output")
+ .flag("-s", "--silent",
+ default = AraraSpec.Execution.verbose.default)
+ private val dryrun by option("-n", "--dry-run",
+ help = "Go through all the motions of running a command, but " +
+ "with no actual calls")
+ .flag(default = AraraSpec.Execution.dryrun.default)
+ private val onlyheader by option("-H", "--header",
+ help = "Extract directives only in the file header")
+ .flag(default = AraraSpec.Execution.onlyHeader.default)
+ private val timeout by option("-t", "--timeout",
+ help = "Set the execution timeout (in milliseconds)")
+ .int().restrictTo(min = 1)
+ private val language by option("-L", "--language",
+ help = "Set the application language")
+ .default(AraraSpec.Application.defaultLanguageCode.default)
+ private val maxLoops by option("-m", "--max-loops",
+ help = "Set the maximum number of loops (> 0)")
+ .int().restrictTo(min = 1)
+ .default(AraraSpec.Execution.maxLoops.default)
+ private val preamble by option("-p", "--preamble",
+ help = "Set the file preamble based on the configuration file")
+ private val workingDirectory by option("-d", "--working-directory",
+ help = "Set the working directory for all tools")
+ .path(exists = true, fileOkay = false, readable = true)
+ .default(AraraSpec.Execution.workingDirectory.default)
+
+ private val reference by argument("file",
+ help = "The file(s) to evaluate and process")
+ .multiple(required = true)
+
+ /**
+ * Update the default configuration with the values parsed from the
+ * command line.
+ */
+ @ExperimentalTime
+ private fun updateConfigurationFromCommandLine() {
+ Arara.config[AraraSpec.Execution.language] = Language(language)
+ LanguageController.setLocale(Arara.config[AraraSpec.Execution.language]
+ .locale)
+
+ Arara.config[AraraSpec.Execution.logging] = log
+ Arara.config[AraraSpec.Execution.verbose] = verbose
+ Arara.config[AraraSpec.Execution.dryrun] = dryrun
+ Arara.config[AraraSpec.Execution.onlyHeader] = onlyheader
+ Arara.config[AraraSpec.Execution.maxLoops] = maxLoops
+ Arara.config[AraraSpec.Execution.workingDirectory] = workingDirectory
+ preamble?.let {
+ val preambles = Arara.config[AraraSpec.Execution.preambles]
+ if (preambles.containsKey(it)) {
+ Arara.config[AraraSpec.Execution.preamblesActive] = true
+ Arara.config[AraraSpec.Execution.preamblesContent] =
+ // will never throw (see check above)
+ preambles.getValue(it)
+ } else {
+ throw AraraException(
+ LanguageController.getMessage(
+ Messages.ERROR_PARSER_INVALID_PREAMBLE, it)
+ )
+ }
+ }
+ timeout?.let {
+ Arara.config[AraraSpec.Execution.timeout] = true
+ Arara.config[AraraSpec.Execution.timeoutValue] = it.milliseconds
+ }
+
+ Arara.config[AraraSpec.UserInteraction.displayTime] = true
+ }
+
+ /**
+ * The actual main method of arara (when run in command-line mode)
+ */
+ @ExperimentalTime
+ override fun run() {
+ // the first component to be initialized is the
+ // logging controller; note init() actually disables
+ // the logging, so early exceptions won't generate
+ // a lot of noise in the terminal
+ LoggingUtils.init()
+
+ // arara features a stopwatch, so we can see how much time has passed
+ // since everything started; internally, this class makes use of
+ // nano time, so we might get an interesting precision here
+ // (although timing is not a serious business in here, it's
+ // just a cool addition)
+ val executionStart: ClockMark = MonoClock.markNow()
+
+ // arara stores the environment variables accessible at the start
+ // of the execution in the session object for the user
+ Session.updateEnvironmentVariables()
+
+ // logging has to be initialized only once and for all because
+ // context resets lead to missing output
+ LoggingUtils.enableLogging(log)
+
+ try {
+ // TODO: this will have to change for parallelization
+ reference.forEach {
+ // TODO: do we have to reset some more file-specific config?
+ // especially the working directory will have to be set and
+ // changed
+ Arara.config = Arara.baseconfig.withLayer(it)
+ // next, update the configuration
+ updateConfigurationFromCommandLine()
+ FileSearchingUtils.discoverFile(it)
+ Arara.run()
+ // add an empty line between file executions
+ println()
+ }
+
+ // this is the last command from arara; once the execution time is
+ // available, print it; note that this notification is suppressed
+ // when the command line parsing returns false as result (it makes
+ // no sense to print the execution time for a help message, I guess)
+ DisplayUtils.printTime(executionStart.elapsedNow().inSeconds)
+ } catch (ex: AraraException) {
+ DisplayUtils.printException(ex)
+ Arara.config[AraraSpec.Execution.status] = 2
+ }
+
+ // gets the application exit status; the rule here is:
+ // 0 : everything went just fine (note that the dry-run mode always
+ // makes arara exit with 0, unless it is an error in the directive
+ // builder itself).
+ // 1 : one of the tasks failed, so the execution ended abruptly. This
+ // means the error relies on the command line call, not with arara.
+ // 2 : arara just handled an exception, meaning that something bad
+ // just happened and might require user intervention.
+ exitProcess(CommonUtils.exitStatus)
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/AraraSpec.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/AraraSpec.kt
new file mode 100644
index 0000000000..14100ec98b
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/AraraSpec.kt
@@ -0,0 +1,90 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.configuration
+
+import com.uchuhimo.konf.ConfigSpec
+import java.io.File
+import java.nio.file.Paths
+import kotlin.time.ExperimentalTime
+import kotlin.time.milliseconds
+import org.islandoftex.arara.localization.Language
+
+/**
+ * Configuration hierarchy for arara
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 5.0
+ */
+@Suppress("MagicNumber")
+object AraraSpec : ConfigSpec() {
+ object Application : ConfigSpec() {
+ val defaultLanguageCode by optional("en")
+ val version by optional(AraraSpec::class.java.`package`.implementationVersion
+ ?: "DEVELOPMENT BUILD")
+ val namePattern by optional("arara:\\s")
+ val width by optional(65)
+ }
+
+ object Execution : ConfigSpec() {
+ val maxLoops by optional(10)
+ val timeout by optional(false)
+ @ExperimentalTime
+ val timeoutValue by optional(0.milliseconds)
+ val haltOnErrors by optional(true)
+
+ val databaseName by optional("arara")
+ val logName by optional("arara")
+
+ val verbose by optional(false)
+ val language by optional(Language(Application.defaultLanguageCode.default))
+ val logging by optional(false)
+ val dryrun by optional(false)
+ val status by optional(0)
+ val fileTypes by optional(ConfigurationUtils.defaultFileTypes)
+ val rulePaths by optional(setOf(
+ ConfigurationUtils.applicationPath.resolve("rules")
+ .toString()
+ ))
+ val preambles by optional(mapOf<String, String>())
+ val preamblesActive by optional(false)
+ val preamblesContent by optional("")
+
+ val workingDirectory by optional(Paths.get(""))
+ val configurationName by optional("[none]")
+ val onlyHeader by optional(false)
+
+ // TODO: these are runtime values, they should be properly
+ // initialized and tested (maybe move them into their own
+ // Spec or session)
+ val reference by optional(File("/tmp/"))
+ val file by optional(File("/tmp/"))
+
+ object InfoSpec : ConfigSpec() {
+ val ruleId by optional<String?>(null)
+ val rulePath by optional<String?>(null)
+ }
+
+ object DirectiveSpec : ConfigSpec() {
+ val lines by optional(listOf<Int>())
+ }
+
+ val filePattern by optional("")
+ }
+
+ object Directive : ConfigSpec() {
+ val linebreakPattern by optional("^\\s*-->\\s(.*)$")
+
+ private const val directivestart = """^\s*(\w+)\s*(:\s*(\{.*\})\s*)?"""
+ private const val pattern = """(\s+(if|while|until|unless)\s+(\S.*))?$"""
+ val directivePattern by optional(directivestart + pattern)
+ }
+
+ object UserInteraction : ConfigSpec() {
+ val lookAndFeel by optional("none")
+ val displayTime by optional(false)
+ val displayLine by optional(true)
+ val displayResult by optional(false)
+ val displayRolling by optional(false)
+ val displayException by optional(false)
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/Configuration.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/Configuration.kt
new file mode 100644
index 0000000000..06abeeb6db
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/Configuration.kt
@@ -0,0 +1,101 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.configuration
+
+import kotlin.time.ExperimentalTime
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.filehandling.FileHandlingUtils
+import org.islandoftex.arara.localization.Language
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+
+/**
+ * Implements the configuration model, which holds the default settings and can
+ * load the configuration file. The idea here is to provide a map that holds
+ * all configuration settings used by model and utilitary classes throughout
+ * the execution. This controller is implemented as a singleton.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object Configuration {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ /**
+ * Loads the application configuration.
+ *
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @ExperimentalTime
+ @Throws(AraraException::class)
+ fun load() {
+ // initialize both file type and language models,
+ // since we can track errors from there instead
+ // of relying on a check on this level
+
+ // get the configuration file, if any
+ val file = ConfigurationUtils.configFile
+ if (file != null) {
+ // set the configuration file name for
+ // logging purposes
+ Arara.config[AraraSpec.Execution.configurationName] =
+ FileHandlingUtils.getCanonicalPath(file)
+
+ // then validate it and update the
+ // configuration accordingly
+ val resource = ConfigurationUtils.loadLocalConfiguration(file)
+ update(resource)
+ }
+
+ // just to be sure, update the
+ // current locale in order to
+ // display localized messages
+ val locale = Arara.config[AraraSpec.Execution.language].locale
+ LanguageController.setLocale(locale)
+ }
+
+ /**
+ * Update the configuration based on the provided map.
+ *
+ * @param resource Map containing the new configuration settings.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ private fun update(resource: LocalConfiguration) {
+ if (resource.paths.isNotEmpty())
+ Arara.config[AraraSpec.Execution.rulePaths] =
+ ConfigurationUtils.normalizePaths(resource.paths)
+
+ if (resource.filetypes.isNotEmpty()) {
+ Arara.config[AraraSpec.Execution.fileTypes] = ConfigurationUtils.normalizeFileTypes(resource.filetypes)
+ }
+
+ Arara.config[AraraSpec.Execution.verbose] = resource.isVerbose
+ Arara.config[AraraSpec.Execution.logging] = resource.isLogging
+ Arara.config[AraraSpec.Execution.onlyHeader] = resource.isHeader
+ Arara.config[AraraSpec.Execution.language] =
+ Language(resource.language)
+ Arara.config[AraraSpec.UserInteraction.lookAndFeel] = resource.laf
+
+ Arara.config[AraraSpec.Execution.databaseName] =
+ ConfigurationUtils.cleanFileName(resource.dbname)
+ Arara.config[AraraSpec.Execution.logName] =
+ ConfigurationUtils.cleanFileName(resource.logname)
+
+ val loops = resource.loops
+ if (loops <= 0) {
+ throw AraraException(messages.getMessage(Messages
+ .ERROR_CONFIGURATION_LOOPS_INVALID_RANGE))
+ } else {
+ Arara.config[AraraSpec.Execution.maxLoops] = loops
+ }
+
+ if (resource.preambles.isNotEmpty())
+ Arara.config[AraraSpec.Execution.preambles] = resource.preambles
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/ConfigurationUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/ConfigurationUtils.kt
new file mode 100644
index 0000000000..31a92c3009
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/ConfigurationUtils.kt
@@ -0,0 +1,157 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.configuration
+
+import com.charleskorn.kaml.Yaml
+import java.io.File
+import java.io.UnsupportedEncodingException
+import java.net.URLDecoder
+import java.nio.file.Path
+import java.nio.file.Paths
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.model.FileType
+import org.islandoftex.arara.utils.CommonUtils
+
+/**
+ * Implements configuration utilitary methods.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object ConfigurationUtils {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ /**
+ * This map contains all file types that arara accepts
+ * and their corresponding search patterns (for comments).
+ */
+ val defaultFileTypePatterns = mapOf(
+ "tex" to "^\\s*%\\s+",
+ "dtx" to "^\\s*%\\s+",
+ "ltx" to "^\\s*%\\s+",
+ "drv" to "^\\s*%\\s+",
+ "ins" to "^\\s*%\\s+"
+ )
+
+ /**
+ * Set of default file types provided by arara.
+ * Initialization may throw AraraException if file types are wrong
+ */
+ val defaultFileTypes: Set<FileType> by lazy {
+ defaultFileTypePatterns
+ .map { (extension, pattern) -> FileType(extension, pattern) }
+ .toSet()
+ }
+
+ /**
+ * The configuration file in use.
+ *
+ * Look for configuration files in the user's working directory first
+ * if no configuration files are found in the user's working directory,
+ * try to look up in a global directory, that is, the user home.
+ */
+ val configFile: File?
+ get() {
+ val names = listOf(".araraconfig.yaml",
+ "araraconfig.yaml", ".arararc.yaml", "arararc.yaml")
+ Arara.config[AraraSpec.Execution.workingDirectory]
+ .let { workingDir ->
+ val first = names
+ .map { workingDir.resolve(it).toFile() }
+ .firstOrNull { it.exists() }
+ if (first != null)
+ return first
+ }
+ CommonUtils.getSystemPropertyOrNull("user.home")?.let { userHome ->
+ return names.map { File(userHome).resolve(it) }
+ .firstOrNull { it.exists() }
+ }
+ return null
+ }
+
+ /**
+ * The canonical absolute application path.
+ *
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ val applicationPath: Path
+ @Throws(AraraException::class)
+ get() {
+ try {
+ var path = Arara::class.java.protectionDomain.codeSource
+ .location.path
+ path = URLDecoder.decode(path, "UTF-8")
+ return Paths.get(path).parent.toAbsolutePath()
+ } catch (exception: UnsupportedEncodingException) {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_GETAPPLICATIONPATH_ENCODING_EXCEPTION
+ ),
+ exception
+ )
+ }
+ }
+
+ /**
+ * Validates the configuration file.
+ *
+ * @param file The configuration file.
+ * @return The configuration file as a resource.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun loadLocalConfiguration(file: File): LocalConfiguration {
+ return file.runCatching {
+ val text = readText()
+ if (!text.startsWith("!config"))
+ throw Exception("Configuration should start with !config")
+ Yaml.default.parse(LocalConfiguration.serializer(),
+ text)
+ }.getOrElse {
+ throw AraraException(messages.getMessage(
+ Messages.ERROR_CONFIGURATION_GENERIC_ERROR), it)
+ }
+ }
+
+ /**
+ * Normalize a list of rule paths, removing all duplicates.
+ *
+ * @param paths The list of rule paths.
+ * @return A list of normalized paths, without duplicates.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun normalizePaths(paths: Iterable<String>): Set<String> =
+ paths.union(AraraSpec.Execution.rulePaths.default)
+
+ /**
+ * Normalize a list of file types, removing all duplicates.
+ *
+ * @param types The list of file types.
+ * @return A list of normalized file types, without duplicates.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun normalizeFileTypes(types: Iterable<FileType>): Set<FileType> =
+ types.union(defaultFileTypes)
+
+ /**
+ * Cleans the file name to avoid invalid entries.
+ *
+ * @param name The file name.
+ * @return A cleaned file name.
+ */
+ fun cleanFileName(name: String): String {
+ val result = File(name).name.trim()
+ return if (result.isEmpty()) "arara" else result.trim()
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/LocalConfiguration.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/LocalConfiguration.kt
new file mode 100644
index 0000000000..959f2a9ab8
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/configuration/LocalConfiguration.kt
@@ -0,0 +1,85 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.configuration
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.model.FileType
+import org.islandoftex.arara.utils.CommonUtils
+import org.mvel2.templates.TemplateRuntime
+
+/**
+ * A local configuration which resembles configuration files in the working
+ * directory.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+@Serializable
+class LocalConfiguration {
+ // rule paths
+ var paths: List<String> = listOf()
+ get() {
+ val user = mapOf(
+ "home" to (CommonUtils.getSystemPropertyOrNull("user.home")
+ ?: ""),
+ "name" to (CommonUtils.getSystemPropertyOrNull("user.name")
+ ?: ""))
+ val application = mapOf(
+ "workingDirectory" to Arara.config[AraraSpec.Execution.workingDirectory].toAbsolutePath().toString()
+ )
+
+ return field.map { input ->
+ var path = CommonUtils.removeKeywordNotNull(input)
+ try {
+ path = TemplateRuntime.eval(path, mapOf(
+ "user" to user, "application" to application
+ )) as String
+ } catch (_: RuntimeException) {
+ // do nothing, gracefully fallback to
+ // the default, unparsed path
+ }
+ path
+ }
+ }
+
+ // file types
+ var filetypes: List<FileType> = listOf()
+
+ // the application language
+ // default to English
+ var language: String = Arara.config[AraraSpec.Application.defaultLanguageCode]
+ get() = CommonUtils.removeKeywordNotNull(field)
+
+ // maximum number of loops
+ var loops: Int = Arara.config[AraraSpec.Execution.maxLoops]
+
+ // verbose flag
+ @SerialName("verbose")
+ var isVerbose: Boolean = Arara.config[AraraSpec.Execution.verbose]
+
+ // logging flag
+ @SerialName("logging")
+ var isLogging: Boolean = Arara.config[AraraSpec.Execution.logging]
+
+ // header flag
+ @SerialName("header")
+ var isHeader: Boolean = Arara.config[AraraSpec.Execution.onlyHeader]
+
+ // database name
+ var dbname: String = Arara.config[AraraSpec.Execution.databaseName]
+ get() = CommonUtils.removeKeywordNotNull(field)
+
+ // log name
+ var logname: String = Arara.config[AraraSpec.Execution.logName]
+ get() = CommonUtils.removeKeywordNotNull(field)
+
+ // map of preambles
+ var preambles: Map<String, String> = Arara.config[AraraSpec.Execution.preambles]
+
+ // look and feel
+ // default to none
+ var laf: String = Arara.config[AraraSpec.UserInteraction.lookAndFeel]
+ get() = CommonUtils.removeKeywordNotNull(field)
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/Database.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/Database.kt
new file mode 100644
index 0000000000..e4b98cd194
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/Database.kt
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.filehandling
+
+import kotlinx.serialization.Serializable
+
+/**
+ * The database model, which keeps track on file changes.
+ *
+ * This database is a map because it maps files to hashes. So the key will
+ * always be a file representation and the value always a string.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+@Serializable
+data class Database(
+ /**
+ * The whole database is implemented as a map, where
+ * the key is the absolute canonical file and the value
+ * is its corresponding CRC32 hash.
+ */
+ val map: MutableMap<String, String> = mutableMapOf()
+) : MutableMap<String, String> by map
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/DatabaseUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/DatabaseUtils.kt
new file mode 100644
index 0000000000..f4728c9366
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/DatabaseUtils.kt
@@ -0,0 +1,97 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.filehandling
+
+import com.charleskorn.kaml.Yaml
+import java.io.File
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+
+/**
+ * Implements database utilitary methods.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object DatabaseUtils {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ /**
+ * Gets the file representing the YAML file (database).
+ *
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ private val file: File
+ @Throws(AraraException::class)
+ get() {
+ val reference = Arara.config[AraraSpec.Execution.reference]
+ val name = "${Arara.config[AraraSpec.Execution.databaseName]}.yaml"
+ val path = FileHandlingUtils.getParentCanonicalFile(reference)
+ return path.resolve(name)
+ }
+
+ /**
+ * Loads the YAML file representing the database.
+ *
+ * @return The database object.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun load(): Database {
+ return if (!exists()) {
+ Database()
+ } else {
+ file.runCatching {
+ val text = readText()
+ if (!text.startsWith("!database"))
+ throw Exception("Database should start with !database")
+ Yaml.default.parse(Database.serializer(), text)
+ }.getOrElse {
+ it.printStackTrace()
+ throw AraraException(messages.getMessage(Messages
+ .ERROR_LOAD_COULD_NOT_LOAD_XML, file.name), it)
+ }
+ }
+ }
+
+ /**
+ * Saves the database on a YAML file.
+ *
+ * @param database The database object.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun save(database: Database) {
+ file.runCatching {
+ val content = "!database\n" +
+ Yaml.default.stringify(Database.serializer(), database)
+ writeText(content)
+ }.getOrElse {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_SAVE_COULD_NOT_SAVE_XML,
+ file.name
+ ), it)
+ }
+ }
+
+ /**
+ * Checks if the YAML file representing the database exists.
+ *
+ * @return A boolean value indicating if the YAML file exists.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ private fun exists(): Boolean {
+ return file.exists()
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/FileHandlingUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/FileHandlingUtils.kt
new file mode 100644
index 0000000000..07f1ecfab9
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/FileHandlingUtils.kt
@@ -0,0 +1,319 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.filehandling
+
+import java.io.File
+import java.io.IOException
+import java.text.SimpleDateFormat
+import java.util.zip.CRC32
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+
+/**
+ * Implements file handling utilitary methods.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object FileHandlingUtils {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ /**
+ * Gets the reference of the current file in execution. Note that this
+ * method might return a value different than the main file provided in
+ * the command line.
+ *
+ * @return A reference of the current file in execution. Might be different
+ * than the main file provided in the command line.
+ */
+ private val currentFile: File
+ get() = Arara.config[AraraSpec.Execution.file]
+
+ /**
+ * Writes the string to a file, using UTF-8 as default encoding.
+ * @param file The file.
+ * @param text The string to be written.
+ * @param append A flag whether to append the content.
+ * @return A logical value indicating whether it was successful.
+ */
+ fun writeToFile(file: File, text: String, append: Boolean): Boolean {
+ return try {
+ // try to write the provided
+ // string to the file, with
+ // UTF-8 as encoding
+ if (append)
+ file.appendText(text, Charsets.UTF_8)
+ else
+ file.writeText(text, Charsets.UTF_8)
+ true
+ } catch (_: IOException) {
+ // if something bad happens,
+ // gracefully fallback to
+ // reporting the failure
+ false
+ }
+ }
+
+ /**
+ * Writes the string list to a file, using UTF-8 as default encoding.
+ * @param file The file.
+ * @param lines The string list to be written.
+ * @param append A flag whether to append the content.
+ * @return A logical value indicating whether it was successful.
+ */
+ fun writeToFile(
+ file: File,
+ lines: List<String>,
+ append: Boolean
+ ): Boolean =
+ try {
+ writeToFile(file, lines.joinToString(System.lineSeparator()),
+ append)
+ } catch (_: IOException) {
+ false
+ }
+
+ /**
+ * Reads the provided file (UTF-8) into a list of strings.
+ * @param file The file.
+ * @return A list of strings.
+ */
+ fun readFromFile(file: File): List<String> {
+ return try {
+ // returns the contents of
+ // the provided file as
+ // a list of strings
+ file.readLines(Charsets.UTF_8)
+ } catch (_: IOException) {
+ // if something bad happens,
+ // gracefully fallback to
+ // an empty file list
+ listOf()
+ }
+ }
+
+ /**
+ * Checks if a file exists based on its extension.
+ *
+ * @param extension The extension.
+ * @return A boolean value indicating if the file exists.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun exists(extension: String): Boolean {
+ val file = File(getPath(extension))
+ return file.exists()
+ }
+
+ /**
+ * Gets the parent canonical path of a file.
+ *
+ * @param file The file.
+ * @return The parent canonical path of a file.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun getParentCanonicalPath(file: File): String {
+ return getParentCanonicalFile(file).toString()
+ }
+
+ /**
+ * Gets the parent canonical file of a file.
+ *
+ * @param file The file.
+ * @return The parent canonical file of a file.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun getParentCanonicalFile(file: File): File {
+ return file.runCatching {
+ this.canonicalFile.parentFile
+ }.getOrElse {
+ // it is IOException || is is SecurityException
+ throw AraraException(messages.getMessage(
+ Messages.ERROR_GETPARENTCANONICALPATH_IO_EXCEPTION), it)
+ }
+ }
+
+ /**
+ * Gets the full file path based on the provided extension.
+ *
+ * @param extension The extension.
+ * @return A string containing the full file path.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun getPath(extension: String): String {
+ val name = currentFile.nameWithoutExtension + ".$extension"
+ val path = getParentCanonicalFile(currentFile)
+ return path.resolve(name).toString()
+ }
+
+ /**
+ * Gets the canonical path from the provided file.
+ *
+ * @param file The file.
+ * @return The canonical path from the provided file.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun getCanonicalPath(file: File): String {
+ return getCanonicalFile(file).toString()
+ }
+
+ /**
+ * Gets the canonical file from the provided file.
+ *
+ * @param file The file.
+ * @return The canonical file from the provided file.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun getCanonicalFile(file: File): File {
+ try {
+ return file.canonicalFile
+ } catch (exception: IOException) {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_GETCANONICALFILE_IO_EXCEPTION
+ ),
+ exception
+ )
+ }
+ }
+
+ /**
+ * Gets the date the provided file was last modified.
+ *
+ * @param file The file.
+ * @return A string representation of the date the provided file was last
+ * modified.
+ */
+ fun getLastModifiedInformation(file: File): String {
+ return SimpleDateFormat("MM/dd/yyyy HH:mm:ss")
+ .format(file.lastModified())
+ }
+
+ /**
+ * Calculates the CRC32 checksum of the provided file.
+ *
+ * @param file The file.
+ * @return A string containing the CRC32 checksum of the provided file.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun calculateHash(file: File): String {
+ try {
+ return String.format("%08x", CRC32().run {
+ update(file.readBytes())
+ value
+ })
+ } catch (exception: IOException) {
+ throw AraraException(messages.getMessage(Messages
+ .ERROR_CALCULATEHASH_IO_EXCEPTION), exception)
+ }
+ }
+
+ /**
+ * Gets the extension of a file.
+ *
+ * @param file The file.
+ * @return The corresponding file type.
+ */
+ fun getFileExtension(file: File): String = file.extension
+
+ /**
+ * Gets the base name of a file.
+ *
+ * @param file The file.
+ * @return The corresponding base name.
+ */
+ fun getBasename(file: File): String = file.nameWithoutExtension
+
+ /**
+ * Checks if a file has changed since the last verification.
+ *
+ * @param file The file.
+ * @return A boolean value indicating if the file has changed since the last
+ * verification.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun hasChanged(file: File): Boolean {
+ val database = DatabaseUtils.load()
+ val path = getCanonicalPath(file)
+ return if (!file.exists()) {
+ if (database.containsKey(path)) {
+ database.remove(path)
+ DatabaseUtils.save(database)
+ true
+ } else {
+ false
+ }
+ } else {
+ val hash = calculateHash(file)
+ if (database.containsKey(path)) {
+ val value = database[path]
+ if (hash == value) {
+ false
+ } else {
+ database[path] = hash
+ DatabaseUtils.save(database)
+ true
+ }
+ } else {
+ database[path] = hash
+ DatabaseUtils.save(database)
+ true
+ }
+ }
+ }
+
+ /**
+ * Checks if the file has changed since the last verification based on the
+ * provided extension.
+ *
+ * @param extension The provided extension.
+ * @return A boolean value indicating if the file has changed since the last
+ * verification.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun hasChanged(extension: String): Boolean =
+ hasChanged(File(getPath(extension)))
+
+ /**
+ * Checks whether a directory is under a root directory.
+ *
+ * @param child Directory to be inspected.
+ * @param parent Root directory.
+ * @return Logical value indicating whether the directoy is under root.
+ * @throws AraraException There was a problem with path retrieval.
+ */
+ @Throws(AraraException::class)
+ fun isSubDirectory(child: File, parent: File): Boolean {
+ return if (child.isDirectory && parent.isDirectory) {
+ getCanonicalPath(child).startsWith(
+ getParentCanonicalPath(parent) + File.separator
+ )
+ } else {
+ throw AraraException(messages.getMessage(
+ Messages.ERROR_ISSUBDIRECTORY_NOT_A_DIRECTORY,
+ child.name))
+ }
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/FileSearchingUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/FileSearchingUtils.kt
new file mode 100644
index 0000000000..5c61bdb2db
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/filehandling/FileSearchingUtils.kt
@@ -0,0 +1,158 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.filehandling
+
+import java.io.File
+import java.io.FileFilter
+import java.nio.file.FileSystems
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.utils.CommonUtils
+
+/**
+ * Implements file searching utilitary methods.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object FileSearchingUtils {
+ /**
+ * List all files from the provided directory according to the list of
+ * extensions. The leading dot must be omitted, unless it is part of the
+ * extension.
+ * @param directory The provided directory.
+ * @param extensions The list of extensions.
+ * @param recursive A flag indicating whether the search is recursive.
+ * @return A list of files.
+ */
+ fun listFilesByExtensions(
+ directory: File,
+ extensions: List<String>,
+ recursive: Boolean
+ ):
+ List<File> = try {
+ // return the result of the
+ // provided search
+ if (recursive)
+ directory.walkTopDown().asSequence()
+ .filter { !it.isDirectory }
+ .filter { extensions.contains(it.extension) }
+ .toList()
+ else
+ directory.listFiles(
+ FileFilter { extensions.contains(it.extension) })!!
+ .toList()
+ } catch (_: Exception) {
+ // if something bad happens,
+ // gracefully fallback to
+ // an empty file list
+ listOf()
+ }
+
+ /**
+ * List all files from the provided directory matching the list of file
+ * name patterns. Such list can contain wildcards.
+ * @param directory The provided directory.
+ * @param patterns The list of file name patterns.
+ * @param recursive A flag indicating whether the search is recursive.
+ * @return A list of files.
+ */
+ fun listFilesByPatterns(
+ directory: File,
+ patterns: List<String>,
+ recursive: Boolean
+ ):
+ List<File> = try {
+ // return the result of the provided
+ // search, with the wildcard filter
+ // and a potential recursive search
+ val pathMatcher = patterns.map {
+ FileSystems.getDefault().getPathMatcher("glob:$it")
+ }
+ if (recursive)
+ directory.walkTopDown().asSequence()
+ .filter { !it.isDirectory }
+ .filter { file ->
+ pathMatcher.any { it.matches(file.toPath().fileName) }
+ }.toList()
+ else
+ directory.listFiles { file: File ->
+ pathMatcher.any { it.matches(file.toPath().fileName) }
+ }!!.toList()
+ } catch (_: Exception) {
+ // if something bad happens,
+ // gracefully fallback to
+ // an empty file list
+ listOf()
+ }
+
+ /**
+ * Discovers the file through string reference lookup and sets the
+ * configuration accordingly.
+ *
+ * @param reference The string reference.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun discoverFile(reference: String) {
+ lookupFile(reference)
+ ?: throw AraraException(
+ LanguageController.getMessage(
+ Messages.ERROR_DISCOVERFILE_FILE_NOT_FOUND,
+ reference,
+ CommonUtils.fileTypesList
+ )
+ )
+ }
+
+ /**
+ * Performs a file lookup based on a string reference.
+ *
+ * @param reference The file reference as a string.
+ * @return The file as result of the lookup operation.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ private fun lookupFile(reference: String): File? {
+ val types = Arara.config[AraraSpec.Execution.fileTypes]
+ val file = Arara.config[AraraSpec.Execution.workingDirectory]
+ .resolve(reference).toFile()
+ val name = file.name
+ val parent = FileHandlingUtils.getParentCanonicalFile(file)
+
+ // direct search, so we are considering
+ // the reference as a complete name
+ val testFile = parent.resolve(name)
+ if (testFile.exists() && testFile.isFile) {
+ types.firstOrNull {
+ testFile.toString().endsWith("." + it.extension)
+ }?.let {
+ Arara.config[AraraSpec.Execution.filePattern] =
+ it.pattern
+ Arara.config[AraraSpec.Execution.reference] = testFile
+ return testFile
+ }
+ }
+
+ // indirect search; in this case, we are considering
+ // that the file reference has an implicit extension,
+ // so we need to add it and look again
+ // TODO: disable this step in safe mode
+ return types.map { parent.resolve("$name.${it.extension}") to it }
+ .union(types.map {
+ parent.resolve("${name.removeSuffix(".").trim()}.${it.extension}") to it
+ })
+ .firstOrNull { it.first.exists() && it.first.isFile }
+ ?.let {
+ Arara.config[AraraSpec.Execution.filePattern] =
+ it.second.pattern
+ Arara.config[AraraSpec.Execution.reference] = it.first
+ file
+ }
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/Language.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/Language.kt
new file mode 100644
index 0000000000..2afa77c41e
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/Language.kt
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.localization
+
+import java.util.Locale
+import org.islandoftex.arara.model.AraraException
+
+/**
+ * Implements the language model.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+class Language(code: String) {
+ // the language code, based on
+ // ISO 639-1 and language variants
+ private val code: String
+
+ /**
+ * Gets the language name.
+ *
+ * @return A string representing the language name.
+ */
+ val name: String
+ get() = languages.getValue(code).first
+
+ /**
+ * Gets the language locale.
+ *
+ * @return The language locale.
+ */
+ val locale: Locale
+ get() = languages.getValue(code).second
+
+ // throws an exception on invalid language
+ init {
+ if (languages.containsKey(code)) {
+ this.code = code
+ } else {
+ throw AraraException(
+ LanguageController.getMessage(
+ Messages.ERROR_LANGUAGE_INVALID_CODE,
+ languagesList
+ )
+ )
+ }
+ }
+
+ companion object {
+ // map containing all languages
+ // supported by arara
+ private val languages = mapOf(
+ "en" to Pair("English", Locale("en")),
+ "de" to Pair("German", Locale("de")),
+ "nl" to Pair("Dutch", Locale("nl")),
+ "qn" to Pair("Broad Norfolk", Locale("en", "QN")),
+ "ptbr" to Pair("Brazilian Portuguese", Locale("pt", "BR")),
+ "it" to Pair("Italian", Locale("it"))
+ )
+
+ /**
+ * String representing the list of available languages
+ * because they don't change initialized with the string
+ */
+ val languagesList: String = "(" + languages.map { (key, value) ->
+ value.first + ": " + key
+ }.joinToString(", ") + ")"
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/LanguageController.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/LanguageController.kt
new file mode 100644
index 0000000000..e000ab4d63
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/LanguageController.kt
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.localization
+
+import ch.qos.cal10n.IMessageConveyor
+import ch.qos.cal10n.MessageConveyor
+import java.util.Locale
+import org.islandoftex.arara.configuration.AraraSpec
+
+/**
+ * Implements the language controller. This controller provides a singleton
+ * object that holds the application messages, easily available to all model
+ * and utilitary classes.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object LanguageController {
+ // the message conveyor helps us to get localized messages
+ // according to the provided locale
+ // The fallback language is set to English for all
+ // messages in arara.
+ private var conveyor: IMessageConveyor = MessageConveyor(Locale(
+ AraraSpec.Application.defaultLanguageCode.default))
+
+ /**
+ * Sets the current locale. This method actually resets the language
+ * conveyor in order to use the new locale. It's quite simple.
+ * @param locale The new locale for localized messages through the language
+ * conveyor.
+ */
+ fun setLocale(locale: Locale) {
+ conveyor = MessageConveyor(locale)
+ }
+
+ /**
+ * Gets the localized message indexed by the provided enumeration key,
+ * applying an array of objects as parameters. This method is a wrapper to
+ * the conveyor's method of the same name.
+ * @param E Enumeration type that represents the conveyor messages.
+ * @param key Key set in the provided enumeration type.
+ * @param parameters Array of objects to be used as parameters.
+ * @return A string containing a localized message indexed by the provided
+ * enumeration key and applied the array of objects as parameters.
+ */
+ @Suppress("SpreadOperator")
+ fun <E : Enum<*>> getMessage(key: E, vararg parameters: Any): String =
+ conveyor.getMessage(key, *parameters)
+
+ /**
+ * Gets the localized message indexed by the provided enumeration key. This
+ * method is a wrapper to the conveyor's method of the same name.
+ * @param E Enumeration type that represents the conveyor messages.
+ * @param key Key set in the provided enumeration type.
+ * @return A string containing a localized message indexed by the provided
+ * enumeration key.
+ */
+ fun <E : Enum<*>> getMessage(key: E): String = conveyor.getMessage(key)
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/Messages.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/Messages.kt
new file mode 100644
index 0000000000..75d957e1df
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/localization/Messages.kt
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.localization
+
+import ch.qos.cal10n.BaseName
+import ch.qos.cal10n.Locale
+import ch.qos.cal10n.LocaleData
+
+/**
+ * This enumeration contains all application messages.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+@BaseName("org.islandoftex.arara.localization.messages")
+@LocaleData(Locale(value = "de", charset = "UTF-8"),
+ Locale(value = "en", charset = "UTF-8"),
+ Locale(value = "en_QN", charset = "UTF-8"),
+ Locale(value = "it", charset = "UTF-8"),
+ Locale(value = "nl", charset = "UTF-8"),
+ Locale(value = "pt_BR", charset = "UTF-8"))
+enum class Messages {
+ ERROR_BASENAME_NOT_A_FILE,
+ ERROR_CALCULATEHASH_IO_EXCEPTION,
+ ERROR_CHECKBOOLEAN_NOT_VALID_BOOLEAN,
+ ERROR_CHECKOS_INVALID_OPERATING_SYSTEM,
+ ERROR_CHECKREGEX_IO_EXCEPTION,
+ ERROR_CONFIGURATION_GENERIC_ERROR,
+ ERROR_CONFIGURATION_LOOPS_INVALID_RANGE,
+ ERROR_DISCOVERFILE_FILE_NOT_FOUND,
+ ERROR_EVALUATE_COMPILATION_FAILED,
+ ERROR_EVALUATE_NOT_BOOLEAN_VALUE,
+ ERROR_EXTRACTOR_IO_ERROR,
+ ERROR_FILETYPE_NOT_A_FILE,
+ ERROR_FILETYPE_UNKNOWN_EXTENSION,
+ ERROR_GETAPPLICATIONPATH_ENCODING_EXCEPTION,
+ ERROR_GETCANONICALFILE_IO_EXCEPTION,
+ ERROR_GETPARENTCANONICALPATH_IO_EXCEPTION,
+ ERROR_INTERPRETER_ARGUMENT_IS_REQUIRED,
+ ERROR_INTERPRETER_COMMAND_RUNTIME_ERROR,
+ ERROR_INTERPRETER_DEFAULT_VALUE_RUNTIME_ERROR,
+ ERROR_INTERPRETER_EXIT_RUNTIME_ERROR,
+ ERROR_INTERPRETER_FLAG_RUNTIME_EXCEPTION,
+ ERROR_INTERPRETER_RULE_NOT_FOUND,
+ ERROR_INTERPRETER_UNKNOWN_KEYS,
+ ERROR_INTERPRETER_WRONG_EXIT_CLOSURE_RETURN,
+ ERROR_ISSUBDIRECTORY_NOT_A_DIRECTORY,
+ ERROR_LANGUAGE_INVALID_CODE,
+ ERROR_LOAD_COULD_NOT_LOAD_XML,
+ ERROR_PARSER_INVALID_PREAMBLE,
+ ERROR_PARSERULE_GENERIC_ERROR,
+ ERROR_REPLICATELIST_MISSING_FORMAT_ARGUMENTS_EXCEPTION,
+ ERROR_RULE_IDENTIFIER_AND_PATH,
+ ERROR_RUN_GENERIC_EXCEPTION,
+ ERROR_RUN_INTERRUPTED_EXCEPTION,
+ ERROR_RUN_INVALID_EXIT_VALUE_EXCEPTION,
+ ERROR_RUN_IO_EXCEPTION,
+ ERROR_RUN_TIMEOUT_EXCEPTION,
+ ERROR_RUN_TIMEOUT_INVALID_RANGE,
+ ERROR_SAVE_COULD_NOT_SAVE_XML,
+ ERROR_SESSION_OBTAIN_UNKNOWN_KEY,
+ ERROR_SESSION_REMOVE_UNKNOWN_KEY,
+ ERROR_VALIDATE_EMPTY_FILES_LIST,
+ ERROR_VALIDATE_FILES_IS_NOT_A_LIST,
+ ERROR_VALIDATE_INVALID_DIRECTIVE_FORMAT,
+ ERROR_VALIDATE_NO_DIRECTIVES_FOUND,
+ ERROR_VALIDATE_ORPHAN_LINEBREAK,
+ ERROR_VALIDATE_REFERENCE_IS_RESERVED,
+ ERROR_VALIDATE_YAML_EXCEPTION,
+ ERROR_VALIDATEBODY_ARGUMENT_ID_IS_RESERVED,
+ ERROR_VALIDATEBODY_DUPLICATE_ARGUMENT_IDENTIFIERS,
+ ERROR_VALIDATEBODY_MISSING_KEYS,
+ ERROR_VALIDATEBODY_NULL_ARGUMENT_ID,
+ ERROR_VALIDATEBODY_NULL_COMMAND,
+ ERROR_VALIDATEHEADER_NULL_ID,
+ ERROR_VALIDATEHEADER_NULL_NAME,
+ ERROR_VALIDATEHEADER_WRONG_IDENTIFIER,
+ INFO_DISPLAY_EXCEPTION_MORE_DETAILS,
+ INFO_DISPLAY_EXECUTION_TIME,
+ INFO_DISPLAY_FILE_INFORMATION,
+ INFO_INTERPRETER_DRYRUN_MODE_BOOLEAN_MODE,
+ INFO_INTERPRETER_DRYRUN_MODE_SYSTEM_COMMAND,
+ INFO_LABEL_AUTHOR,
+ INFO_LABEL_AUTHORS,
+ INFO_LABEL_CONDITIONAL,
+ INFO_LABEL_NO_AUTHORS,
+ INFO_LABEL_ON_DETAILS,
+ INFO_LABEL_ON_ERROR,
+ INFO_LABEL_ON_FAILURE,
+ INFO_LABEL_ON_SUCCESS,
+ INFO_LABEL_UNNAMED_TASK,
+ INFO_PARSER_NOTES,
+ LOG_INFO_BEGIN_BUFFER,
+ LOG_INFO_BOOLEAN_MODE,
+ LOG_INFO_DIRECTIVES_BLOCK,
+ LOG_INFO_END_BUFFER,
+ LOG_INFO_INTERPRET_RULE,
+ LOG_INFO_INTERPRET_TASK,
+ LOG_INFO_POTENTIAL_DIRECTIVE_FOUND,
+ LOG_INFO_POTENTIAL_PATTERN_FOUND,
+ LOG_INFO_RULE_LOCATION,
+ LOG_INFO_SYSTEM_COMMAND,
+ LOG_INFO_TASK_RESULT,
+ LOG_INFO_VALIDATED_DIRECTIVES,
+ LOG_INFO_WELCOME_MESSAGE
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/AraraException.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/AraraException.kt
new file mode 100644
index 0000000000..6bf2d1e617
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/AraraException.kt
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.model
+
+/**
+ * Implements the specific exception model for arara.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+class AraraException : Exception {
+ /**
+ * The underlying exception, used to hold more details
+ * on what really happened
+ */
+ val exception: Exception?
+
+ /**
+ * Constructor. Takes the exception message.
+ * @param message The exception message.
+ */
+ constructor(message: String) : super(message) {
+ this.exception = null
+ }
+
+ /**
+ * Constructor. Takes the exception message and the underlying exception.
+ * @param message The exception message.
+ * @param exception The underlying exception object.
+ */
+ constructor(message: String, exception: Exception) : super(message) {
+ this.exception = exception
+ }
+
+ /**
+ * Constructor. Takes the exception message and the underlying exception.
+ * @param message The exception message.
+ * @param throwable The underlying exception as generic throwable.
+ */
+ constructor(message: String, throwable: Throwable) : super(message) {
+ this.exception = RuntimeException(throwable)
+ }
+
+ /**
+ * Checks if there is an underlying exception defined in the current object.
+ * @return A boolean value indicating if the current object has an
+ * underlying exception.
+ */
+ fun hasException(): Boolean = exception?.message != null
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Evaluator.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Evaluator.kt
new file mode 100644
index 0000000000..78b98e709a
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Evaluator.kt
@@ -0,0 +1,122 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.model
+
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.ruleset.Conditional
+import org.islandoftex.arara.utils.Methods
+import org.mvel2.templates.TemplateRuntime
+
+/**
+ * Implements the evaluator model, on which a conditional can be analyzed and
+ * processed.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+class Evaluator {
+ // this attribute holds the maximum number of
+ // loops arara will accept; it's like
+ // reaching infinity
+ private val loops: Int = Arara.config[AraraSpec.Execution.maxLoops]
+
+ // the counter for the current execution, it
+ // helps us keep track of the number of times
+ // this evaluation has happened, and also to
+ // prevent potential infinite loops
+ private var counter: Int = 0
+
+ // a flag that indicates the
+ // evaluation to halt regardless
+ // of the the result
+ private var halt: Boolean = false
+
+ /**
+ * Check if a condition is of type if or unless and whether halt
+ * is set.
+ * @param type The type to check.
+ * @param haltCheck The value [halt] should have.
+ * @return `(type == if || type == unless) && haltCheck`
+ */
+ private fun isIfUnlessAndHalt(
+ type: Conditional.ConditionalType,
+ haltCheck: Boolean = true
+ ): Boolean =
+ (type == Conditional.ConditionalType.IF ||
+ type == Conditional.ConditionalType.UNLESS) &&
+ halt == haltCheck
+
+ /**
+ * Only run the evaluation of the conditional including a check whether
+ * the result needs to be inverted.
+ * @param conditional The conditional.
+ * @return The result of the evaluation.
+ */
+ @Throws(AraraException::class, RuntimeException::class)
+ private fun evaluateCondition(conditional: Conditional): Boolean {
+ val result = TemplateRuntime.eval("@{ " + conditional.condition + " }",
+ Methods.getConditionalMethods())
+ return if (result is Boolean) {
+ if (conditional.type == Conditional.ConditionalType.UNLESS ||
+ conditional.type == Conditional.ConditionalType.UNTIL)
+ !result
+ else
+ result
+ } else {
+ throw AraraException(messages.getMessage(
+ Messages.ERROR_EVALUATE_NOT_BOOLEAN_VALUE))
+ }
+ }
+
+ /**
+ * Evaluate the provided conditional.
+ *
+ * @param conditional The conditional object.
+ * @return A boolean value indicating if the conditional holds.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ @Suppress("TooGenericExceptionCaught")
+ fun evaluate(conditional: Conditional): Boolean {
+ // when in dry-run mode or not evaluating a
+ // conditional, arara always ignores conditional
+ // evaluations
+ if (conditional.type == Conditional.ConditionalType.NONE ||
+ Arara.config[AraraSpec.Execution.dryrun] ||
+ isIfUnlessAndHalt(conditional.type, true))
+ return false
+ else if (isIfUnlessAndHalt(conditional.type, false)) {
+ halt = true
+ }
+
+ // check counters and see if the execution
+ // has reached our concept of infinity,
+ // thus breaking the cycles
+ counter++
+ return when {
+ conditional.type === Conditional.ConditionalType.WHILE
+ && counter > loops -> false
+ conditional.type === Conditional.ConditionalType.UNTIL
+ && counter >= loops -> false
+ else -> {
+ try {
+ evaluateCondition(conditional)
+ } catch (exception: RuntimeException) {
+ throw AraraException(messages.getMessage(Messages
+ .ERROR_EVALUATE_COMPILATION_FAILED),
+ exception)
+ }
+ }
+ }
+ }
+
+ companion object {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Extractor.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Extractor.kt
new file mode 100644
index 0000000000..ed4d811d22
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Extractor.kt
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.model
+
+import java.io.File
+import java.io.IOException
+import java.nio.charset.Charset
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.ruleset.Directive
+import org.islandoftex.arara.ruleset.DirectiveUtils
+import org.islandoftex.arara.utils.CommonUtils
+
+/**
+ * Extractor for directives from the provided main file.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object Extractor {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ /**
+ * Extracts a list of directives from the provided main file, obtained from
+ * the configuration controller.
+ * @param file The file to extract the directives from.
+ * @param charset The charset of the file.
+ * @return A list of directives.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun extract(file: File, charset: Charset = Charsets.UTF_8):
+ List<Directive> {
+ try {
+ val content = CommonUtils.preambleContent.toMutableList()
+ content.addAll(file.readLines(charset))
+ return DirectiveUtils.extractDirectives(content)
+ } catch (ioexception: IOException) {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_EXTRACTOR_IO_ERROR
+ ),
+ ioexception
+ )
+ }
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/FileType.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/FileType.kt
new file mode 100644
index 0000000000..e6ed27d51a
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/FileType.kt
@@ -0,0 +1,110 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.model
+
+import java.util.regex.PatternSyntaxException
+import kotlinx.serialization.Serializable
+import org.islandoftex.arara.configuration.ConfigurationUtils
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.utils.CommonUtils
+
+/**
+ * Implements the file type model.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+@Serializable
+class FileType {
+ // string representing the
+ // file extension
+ var extension: String = INVALID_EXTENSION
+ get() = CommonUtils.removeKeywordNotNull(field)
+ private set
+ // string representing the
+ // file pattern to be used
+ // as directive lookup
+ var pattern: String = INVALID_PATTERN
+ @Throws(AraraException::class)
+ get() {
+ CommonUtils.removeKeywordNotNull(field)
+ if (field == INVALID_PATTERN) {
+ field = ConfigurationUtils.defaultFileTypePatterns[extension]
+ ?: throw AraraException(
+ LanguageController.getMessage(
+ Messages.ERROR_FILETYPE_UNKNOWN_EXTENSION,
+ extension,
+ CommonUtils.fileTypesList
+ )
+ )
+ }
+ return field
+ }
+ private set
+
+ constructor(extension: String, pattern: String) {
+ this.extension = extension
+ this.pattern = pattern
+
+ try {
+ pattern.toPattern()
+ } catch (e: PatternSyntaxException) {
+ if (!ConfigurationUtils.defaultFileTypePatterns.containsKey(extension))
+ throw AraraException(
+ LanguageController.getMessage(
+ Messages.ERROR_FILETYPE_UNKNOWN_EXTENSION,
+ extension,
+ CommonUtils.fileTypesList
+ )
+ )
+ }
+ }
+
+ companion object {
+ /**
+ * This constant identifies an invalid extension. As unices do not
+ * allow a forward and Windows does not allow a backward slash, this
+ * should suffice.
+ */
+ const val INVALID_EXTENSION = "/\\"
+ /**
+ * This constant identifies an invalid pattern. This is a opening
+ * character class which is invalid.
+ */
+ const val INVALID_PATTERN = "["
+ }
+
+ /**
+ * Provides a textual representation of the current file type object.
+ * @return A string containing a textual representation of the current file
+ * type object.
+ */
+ override fun toString(): String {
+ return ".$extension"
+ }
+
+ /**
+ * Implements the file type equals method, checking if one file type is
+ * equal to another. Note that only the file extension is considered.
+ * @param other The object to be analyzed.
+ * @return A boolean value indicating if those two objects are equal.
+ */
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as FileType
+ if (extension != other.extension) return false
+ return true
+ }
+
+ /**
+ * Implements the file type hash code. Note that only the file extension is
+ * considered.
+ * @return An integer representing the file type hash code.
+ */
+ override fun hashCode(): Int {
+ return extension.hashCode()
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Interpreter.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Interpreter.kt
new file mode 100644
index 0000000000..49cf40f1a2
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Interpreter.kt
@@ -0,0 +1,375 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.model
+
+import java.io.File
+import kotlin.time.ExperimentalTime
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.ruleset.Argument
+import org.islandoftex.arara.ruleset.Command
+import org.islandoftex.arara.ruleset.Conditional
+import org.islandoftex.arara.ruleset.Directive
+import org.islandoftex.arara.ruleset.Rule
+import org.islandoftex.arara.ruleset.RuleCommand
+import org.islandoftex.arara.ruleset.RuleUtils
+import org.islandoftex.arara.utils.CommonUtils
+import org.islandoftex.arara.utils.DisplayUtils
+import org.islandoftex.arara.utils.InterpreterUtils
+import org.islandoftex.arara.utils.Methods
+import org.mvel2.templates.TemplateRuntime
+import org.slf4j.LoggerFactory
+
+/**
+ * Interprets the list of directives.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+class Interpreter(
+ /**
+ * The list of directives to be interpreted and evaluated.
+ */
+ val directives: List<Directive>
+) {
+ /**
+ * Exception class to represent that the interpreter should stop for some
+ * reason
+ */
+ private class HaltExpectedException(msg: String) : Exception(msg)
+
+ /**
+ * Gets the rule according to the provided directive.
+ *
+ * @param directive The provided directive.
+ * @return The absolute canonical path of the rule, given the provided
+ * directive.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ private fun getRule(directive: Directive): File {
+ return InterpreterUtils.buildRulePath(directive.identifier)
+ ?: throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_INTERPRETER_RULE_NOT_FOUND,
+ directive.identifier,
+ "(" + CommonUtils.allRulePaths
+ .joinToString("; ") + ")"
+ )
+ )
+ }
+
+ // TODO: in the following, extract the printing into the higher level
+ // function
+ /**
+ * "Run" a boolean return value
+ * @param value The boolean.
+ * @param conditional The conditional to print in dry-run mode.
+ * @param authors The authors of the rule.
+ * @return Returns [value]
+ */
+ private fun runBoolean(
+ value: Boolean,
+ conditional: Conditional,
+ authors: List<String>
+ ): Boolean {
+ logger.info(messages.getMessage(Messages.LOG_INFO_BOOLEAN_MODE,
+ value.toString()))
+
+ if (Arara.config[AraraSpec.Execution.dryrun]) {
+ DisplayUtils.printAuthors(authors)
+ DisplayUtils.wrapText(messages.getMessage(Messages
+ .INFO_INTERPRETER_DRYRUN_MODE_BOOLEAN_MODE,
+ value))
+ DisplayUtils.printConditional(conditional)
+ }
+
+ return value
+ }
+
+ /**
+ * Run a command
+ * @param command The command to run.
+ * @param conditional The conditional applied to the run (only for printing).
+ * @param authors The rule authors (only for printing).
+ * @param ruleCommandExitValue The exit value of the rule command.
+ * @return Success of the execution.
+ * @throws AraraException Execution failed.
+ */
+ @ExperimentalTime
+ @Throws(AraraException::class)
+ @Suppress("TooGenericExceptionCaught")
+ private fun runCommand(
+ command: Command,
+ conditional: Conditional,
+ authors: List<String>,
+ ruleCommandExitValue: String?
+ ): Boolean {
+ logger.info(messages.getMessage(Messages.LOG_INFO_SYSTEM_COMMAND,
+ command))
+ var success = true
+
+ if (!Arara.config[AraraSpec.Execution.dryrun]) {
+ val code = InterpreterUtils.run(command)
+ val check: Any = try {
+ val context = mapOf<String, Any>("value" to code)
+ TemplateRuntime.eval(
+ "@{ " + (ruleCommandExitValue ?: "value == 0") + " }",
+ context)
+ } catch (exception: RuntimeException) {
+ throw AraraException(CommonUtils.ruleErrorHeader +
+ messages.getMessage(Messages
+ .ERROR_INTERPRETER_EXIT_RUNTIME_ERROR),
+ exception)
+ }
+
+ success = if (check is Boolean) {
+ check
+ } else {
+ throw AraraException(
+ CommonUtils.ruleErrorHeader + messages.getMessage(
+ Messages.ERROR_INTERPRETER_WRONG_EXIT_CLOSURE_RETURN
+ )
+ )
+ }
+ } else {
+ DisplayUtils.printAuthors(authors)
+ DisplayUtils.wrapText(messages.getMessage(
+ Messages.INFO_INTERPRETER_DRYRUN_MODE_SYSTEM_COMMAND,
+ command))
+ DisplayUtils.printConditional(conditional)
+ }
+
+ return success
+ }
+
+ /**
+ * Converts the command evaluation result to a flat list.
+ * @param result The result
+ * @return A flat list.
+ */
+ private fun resultToList(result: Any) = if (result is List<*>) {
+ CommonUtils.flatten(result)
+ } else {
+ listOf(result)
+ }
+
+ /**
+ * Execute a command.
+ * @param command The command to evaluate.
+ * @param conditional Under which condition to execute.
+ * @param rule The rule (only passed for output purposes).
+ * @param parameters The parameters for evaluation
+ * @throws AraraException Running the command failed.
+ */
+ @ExperimentalTime
+ @Throws(AraraException::class)
+ @Suppress("TooGenericExceptionCaught", "ThrowsCount")
+ private fun executeCommand(
+ command: RuleCommand,
+ conditional: Conditional,
+ rule: Rule,
+ parameters: Map<String, Any>
+ ) {
+ val result: Any = try {
+ TemplateRuntime.eval(command.command!!, parameters)
+ } catch (exception: RuntimeException) {
+ throw AraraException(CommonUtils.ruleErrorHeader +
+ messages.getMessage(Messages
+ .ERROR_INTERPRETER_COMMAND_RUNTIME_ERROR),
+ exception)
+ }
+
+ // TODO: check nullability
+ resultToList(result).filter { it.toString().isNotBlank() }
+ .forEach { current ->
+ DisplayUtils.printEntry(rule.name, command.name
+ ?: messages.getMessage(Messages
+ .INFO_LABEL_UNNAMED_TASK))
+
+ val success = when (current) {
+ is Boolean -> runBoolean(current, conditional,
+ rule.authors)
+ is Command -> runCommand(current, conditional,
+ rule.authors, command.exit)
+ else -> TODO("error: this should not happen" +
+ "we are only supporting boolean + command")
+ }
+
+ DisplayUtils.printEntryResult(success)
+
+ if (Arara.config[AraraSpec.Execution.haltOnErrors] && !success)
+ // TODO: localize
+ throw HaltExpectedException("Command failed")
+
+ // TODO: document this key
+ val haltKey = "arara:${Arara.config[AraraSpec
+ .Execution.reference].name}:halt"
+ if (Session.contains(haltKey)) {
+ Arara.config[AraraSpec.Execution.status] =
+ Session[haltKey].toString().toInt()
+ // TODO: localize
+ throw HaltExpectedException("User requested halt")
+ }
+ }
+ }
+
+ /**
+ * Executes each directive, throwing an exception if something bad has
+ * happened.
+ *
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @ExperimentalTime
+ @Throws(AraraException::class)
+ @Suppress("NestedBlockDepth")
+ fun execute() {
+ for (directive in directives) {
+ logger.info(messages.getMessage(Messages.LOG_INFO_INTERPRET_RULE,
+ directive.identifier))
+
+ Arara.config[AraraSpec.Execution.file] =
+ directive.parameters.getValue("reference") as File
+ val file = getRule(directive)
+
+ logger.info(messages.getMessage(Messages.LOG_INFO_RULE_LOCATION,
+ file.parent))
+
+ Arara.config[AraraSpec.Execution.InfoSpec.ruleId] =
+ directive.identifier
+ Arara.config[AraraSpec.Execution.InfoSpec.rulePath] =
+ file.parent
+ Arara.config[AraraSpec.Execution.DirectiveSpec.lines] =
+ directive.lineNumbers
+
+ // parse the rule identified by the directive
+ // (may throw an exception)
+ val rule = RuleUtils.parseRule(file, directive.identifier)
+ val parameters = parseArguments(rule, directive)
+ .plus(Methods.getRuleMethods())
+
+ val evaluator = Evaluator()
+
+ var available = true
+ if (InterpreterUtils.runPriorEvaluation(directive.conditional)) {
+ available = evaluator.evaluate(directive.conditional)
+ }
+
+ // if this directive is conditionally disabled, skip
+ if (!available) continue
+ // if not execute the commands associated with the directive
+ do {
+ rule.commands.forEach { command ->
+ try {
+ executeCommand(command, directive.conditional, rule, parameters)
+ } catch (_: HaltExpectedException) {
+ // if the user uses the halt rule to trigger
+ // a halt, this will be raised
+ return
+ }
+ }
+ } while (evaluator.evaluate(directive.conditional))
+ }
+ }
+
+ /**
+ * Parses the rule arguments against the provided directive.
+ *
+ * @param rule The rule object.
+ * @param directive The directive.
+ * @return A map containing all arguments resolved according to the
+ * directive parameters.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ private fun parseArguments(rule: Rule, directive: Directive):
+ Map<String, Any> {
+ val arguments = rule.arguments
+ val unknown = CommonUtils.getUnknownKeys(directive.parameters,
+ arguments).minus("reference")
+ if (unknown.isNotEmpty())
+ throw AraraException(CommonUtils.ruleErrorHeader +
+ messages.getMessage(
+ Messages.ERROR_INTERPRETER_UNKNOWN_KEYS,
+ "(" + unknown.joinToString(", ") + ")"))
+
+ val resolvedArguments = mutableMapOf<String, Any>()
+ resolvedArguments["reference"] = directive.parameters
+ .getValue("reference")
+
+ val context = mapOf(
+ "parameters" to directive.parameters,
+ "reference" to directive.parameters.getValue("reference")
+ ).plus(Methods.getRuleMethods())
+
+ arguments.forEach { argument ->
+ resolvedArguments[argument.identifier!!] = processArgument(argument,
+ directive.parameters.containsKey(argument.identifier!!),
+ context)
+ }
+
+ return resolvedArguments
+ }
+
+ /**
+ * Process a single argument and return the evaluated result.
+ * @param argument The argument to process.
+ * @param idInDirectiveParams Whether the argument's identifier is
+ * contained in the directive's parameters field.
+ * @param context The context for the evaluation.
+ * @return The result of the evaluation.
+ * @throws AraraException The argument could not be processed.
+ */
+ @Throws(AraraException::class)
+ @Suppress("TooGenericExceptionCaught", "ThrowsCount")
+ private fun processArgument(
+ argument: Argument,
+ idInDirectiveParams: Boolean,
+ context: Map<String, Any>
+ ): Any {
+ if (argument.isRequired && !idInDirectiveParams)
+ throw AraraException(CommonUtils.ruleErrorHeader +
+ messages.getMessage(
+ Messages.ERROR_INTERPRETER_ARGUMENT_IS_REQUIRED,
+ argument.identifier!!))
+
+ var ret = argument.default?.let {
+ try {
+ TemplateRuntime.eval(it, context)
+ } catch (exception: RuntimeException) {
+ throw AraraException(CommonUtils.ruleErrorHeader +
+ messages.getMessage(Messages
+ .ERROR_INTERPRETER_DEFAULT_VALUE_RUNTIME_ERROR),
+ exception)
+ }
+ } ?: ""
+
+ if (argument.flag != null && idInDirectiveParams) {
+ ret = try {
+ TemplateRuntime.eval(argument.flag!!, context)
+ } catch (exception: RuntimeException) {
+ throw AraraException(CommonUtils.ruleErrorHeader + messages
+ .getMessage(Messages
+ .ERROR_INTERPRETER_FLAG_RUNTIME_EXCEPTION),
+ exception)
+ }
+ }
+
+ return ret
+ }
+
+ companion object {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ // the class logger obtained from
+ // the logger factory
+ private val logger = LoggerFactory.getLogger(Interpreter::class.java)
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Session.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Session.kt
new file mode 100644
index 0000000000..c2093429e1
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/model/Session.kt
@@ -0,0 +1,119 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.model
+
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+
+/**
+ * Implements the session.
+ *
+ * This class wraps a map that holds the execution session, that is, a dirty
+ * maneuver to exchange pretty much any data between commands and even rules.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object Session {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ // the session map which holds the execution session;
+ // the idea here is to provide wrappers to the map
+ // methods, so it could be easily manipulated
+ private val map = mutableMapOf<String, Any>()
+
+ /**
+ * Gets the object indexed by the provided key from the session. This method
+ * holds the map method of the very same name.
+ *
+ * @param key The provided key.
+ * @return The object indexed by the provided key.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ operator fun get(key: String): Any {
+ return if (contains(key)) {
+ map.getValue(key)
+ } else {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_SESSION_OBTAIN_UNKNOWN_KEY,
+ key
+ )
+ )
+ }
+ }
+
+ /**
+ * Inserts (or overwrites) the object indexed by the provided key into the
+ * session. This method holds the map method of the very same name.
+ *
+ * @param key The provided key.
+ * @param value The value to be inserted.
+ */
+ fun put(key: String, value: Any) {
+ map[key] = value
+ }
+
+ /**
+ * Removes the entry indexed by the provided key from the session. This method
+ * holds the map method of the same name.
+ *
+ * @param key The provided key.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun remove(key: String) {
+ if (contains(key)) {
+ map.remove(key)
+ } else {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_SESSION_REMOVE_UNKNOWN_KEY,
+ key
+ )
+ )
+ }
+ }
+
+ /**
+ * Checks if the provided key exists in the session.
+ *
+ * @param key The provided key.
+ * @return A boolean value indicating if the provided key exists in the
+ * session.
+ */
+ operator fun contains(key: String): Boolean = map.containsKey(key)
+
+ /**
+ * Clears the session (map). This method, as usual, holds the map method of
+ * the same name.
+ */
+ fun clear() = map.clear()
+
+ /**
+ * Update the environment variables stored in the session.
+ *
+ * @param additionFilter Which environment variables to include. You can
+ * filter their names (the string parameter) but not their values. By
+ * default all values will be added.
+ * @param removalFilter Which environment variables to remove beforehand.
+ * By default all values will be removed.
+ */
+ fun updateEnvironmentVariables(
+ additionFilter: (String) -> Boolean = { true },
+ removalFilter: (String) -> Boolean = { true }
+ ) {
+ // remove all current environment variables to clean up the session
+ map.filterKeys { it.startsWith("environment:") }
+ .filterKeys(removalFilter)
+ .forEach { remove(it.key) }
+ // add all relevant new environment variables
+ System.getenv().filterKeys(additionFilter)
+ .forEach { map["environment:${it.key}"] = it.value }
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Argument.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Argument.kt
new file mode 100644
index 0000000000..dfd2b99403
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Argument.kt
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.ruleset
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import org.islandoftex.arara.utils.CommonUtils
+
+/**
+ * The rule argument model.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+@Serializable
+class Argument {
+ /**
+ * The argument identifier
+ */
+ var identifier: String? = null
+ get() = CommonUtils.removeKeyword(field)
+
+ /**
+ * Boolean indicating if the current argument is required
+ */
+ @SerialName("required")
+ var isRequired: Boolean = false
+
+ /**
+ * Flag to hold the argument value manipulation
+ */
+ var flag: String? = null
+ get() = CommonUtils.removeKeyword(field)
+
+ /**
+ * The argument fallback if it is not defined in the directive
+ */
+ var default: String? = null
+ get() = CommonUtils.removeKeyword(field)
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Command.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Command.kt
new file mode 100644
index 0000000000..df1ce802f5
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Command.kt
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.ruleset
+
+import java.io.File
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.utils.CommonUtils
+
+/**
+ * Implements a command model, containing a list of strings.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+class Command {
+ /**
+ * A list of elements which are components
+ * of a command and represented as strings
+ */
+ val elements: List<String>
+
+ /**
+ * An optional file acting as a reference for
+ * the default working directory
+ */
+ var workingDirectory: File = Arara.config[AraraSpec.Execution
+ .workingDirectory].toFile()
+
+ /**
+ * Constructor.
+ * @param values An array of objects.
+ */
+ constructor(vararg values: Any) {
+ elements = mutableListOf()
+ val result = CommonUtils.flatten(values.toList())
+ result.map { it.toString() }.filter { it.isNotEmpty() }
+ .forEach { elements.add(it) }
+ }
+
+ /**
+ * Constructor.
+ * @param elements A list of strings.
+ */
+ constructor(elements: List<String>) {
+ this.elements = elements
+ }
+
+ /**
+ * Provides a textual representation of the current command.
+ * @return A string representing the current command.
+ */
+ override fun toString(): String {
+ return "[ " + elements.joinToString(", ") + " ]" +
+ " @ $workingDirectory"
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Conditional.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Conditional.kt
new file mode 100644
index 0000000000..8a4ec6cb35
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Conditional.kt
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.ruleset
+
+/**
+ * The conditional class, it represents the type of conditional available
+ * for a directive and its corresponding expression to be evaluated.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+data class Conditional(
+ /**
+ * The type of the condition indicates the meaning when evaluated.
+ * Defaults to [ConditionalType.NONE].
+ */
+ val type: ConditionalType = ConditionalType.NONE,
+ /**
+ * The expression to be evaluated according to its type. Defaults
+ * to no evaluation (empty string).
+ */
+ val condition: String = ""
+) {
+ /**
+ * The types of conditionals arara is able to recognize.
+ */
+ enum class ConditionalType {
+ /**
+ * Evaluated beforehand, directive is interpreted if and only if the
+ * result is true.
+ */
+ IF,
+ /**
+ * There is no evaluation, directive is interpreted, no extra effort is
+ * needed.
+ */
+ NONE,
+ /**
+ * Evaluated beforehand, directive is interpreted if and only if the
+ * result is false.
+ */
+ UNLESS,
+ /**
+ * Directive is interpreted the first time, then the evaluation is
+ * done; while the result is false, the directive is interpreted again
+ * and again.
+ */
+ UNTIL,
+ /**
+ * Evaluated beforehand, directive is interpreted if and only if the
+ * result is true, and the process is repeated while the result still
+ * holds true.
+ */
+ WHILE
+ }
+
+ /**
+ * Provides a textual representation of the conditional object.
+ * @return A string representation of this object.
+ */
+ override fun toString(): String {
+ return "{ $type" +
+ if (type != ConditionalType.NONE)
+ ", expression: ${condition.trim()}"
+ else "" + " }"
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Directive.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Directive.kt
new file mode 100644
index 0000000000..33f8431744
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Directive.kt
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.ruleset
+
+/**
+ * Implements the directive model.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+data class Directive(
+ /**
+ * The directive identifier, it is resolved to the rule identifier
+ * later on.
+ */
+ val identifier: String,
+ /**
+ * A map containing the parameters; they are validated later on in
+ * order to ensure they are valid.
+ */
+ val parameters: Map<String, Any>,
+ /**
+ * A conditional containing the type and the expression to be evaluated
+ * later on.
+ */
+ val conditional: Conditional,
+ /**
+ * A list contained all line numbers from the main file which built the
+ * current directive.
+ */
+ val lineNumbers: List<Int>
+)
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/DirectiveAssembler.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/DirectiveAssembler.kt
new file mode 100644
index 0000000000..a431df6ae2
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/DirectiveAssembler.kt
@@ -0,0 +1,55 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.ruleset
+
+/**
+ * Implements a directive assembler in order to help build a directive from a
+ * list of strings.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+class DirectiveAssembler {
+ // this variable holds a list of
+ // line numbers indicating which
+ // lines composed the resulting
+ // potential directive
+ private val lineNumbers = mutableListOf<Int>()
+
+ // this variable holds the textual
+ // representation of the directive
+ private var text: String = ""
+
+ /**
+ * Checks if an append operation is allowed.
+ * @return A boolean value indicating if an append operation is allowed.
+ */
+ val isAppendAllowed: Boolean
+ get() = lineNumbers.isNotEmpty()
+
+ /**
+ * Adds a line number to the assembler.
+ * @param line An integer representing the line number.
+ */
+ fun addLineNumber(line: Int) = lineNumbers.add(line)
+
+ /**
+ * Appends the provided line to the assembler text.
+ * @param line The provided line.
+ */
+ fun appendLine(line: String) {
+ text = text + " " + line.trim()
+ }
+
+ /**
+ * Gets the list of line numbers.
+ * @return The list of line numbers.
+ */
+ fun getLineNumbers(): List<Int> = lineNumbers
+
+ /**
+ * Gets the text.
+ * @return The assembler text, properly trimmed.
+ */
+ fun getText(): String = text.trim()
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/DirectiveUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/DirectiveUtils.kt
new file mode 100644
index 0000000000..e901e25fcf
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/DirectiveUtils.kt
@@ -0,0 +1,312 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.ruleset
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
+import com.fasterxml.jackson.module.kotlin.readValue
+import com.fasterxml.jackson.module.kotlin.registerKotlinModule
+import java.io.File
+import java.util.regex.Pattern
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.filehandling.FileHandlingUtils
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.utils.DisplayUtils
+import org.slf4j.LoggerFactory
+
+/**
+ * Implements directive utilitary methods.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+@UseExperimental(kotlinx.serialization.ImplicitReflectionSerializer::class)
+object DirectiveUtils {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ // get the logger context from a factory
+ private val logger = LoggerFactory.getLogger(DirectiveUtils::class.java)
+
+ /**
+ * This function filters the lines of a file to identify the potential
+ * directives.
+ *
+ * @param lines The lines of the file.
+ * @return A map containing the line number and the line's content.
+ */
+ private fun getPotentialDirectiveLines(lines: List<String>):
+ Map<Int, String> {
+ val header = Arara.config[AraraSpec.Execution.onlyHeader]
+ val validLineRegex = Arara.config[AraraSpec.Execution.filePattern]
+ val validLinePattern = validLineRegex.toPattern()
+ val validLineStartPattern = (validLineRegex + Arara.config[AraraSpec
+ .Application.namePattern]).toPattern()
+ val map = mutableMapOf<Int, String>()
+ for ((i, text) in lines.withIndex()) {
+ val validLineMatcher = validLineStartPattern.matcher(text)
+ if (validLineMatcher.find()) {
+ val line = text.substring(validLineMatcher.end())
+ map[i + 1] = line
+
+ logger.info(messages.getMessage(
+ Messages.LOG_INFO_POTENTIAL_PATTERN_FOUND,
+ i + 1, line.trim()))
+ } else if (header && !checkLinePattern(validLinePattern, text)) {
+ // if we should only look within the file's header and reached
+ // a point where the line pattern does not match anymore, we
+ // assume we have left the header and break
+ break
+ }
+ }
+ return map
+ }
+
+ /**
+ * Extracts a list of directives from a list of strings.
+ *
+ * @param lines List of strings.
+ * @return A list of directives.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ @Suppress("MagicNumber")
+ fun extractDirectives(lines: List<String>): List<Directive> {
+ val pairs = getPotentialDirectiveLines(lines)
+ .takeIf { it.isNotEmpty() }
+ ?: throw AraraException(messages.getMessage(
+ Messages.ERROR_VALIDATE_NO_DIRECTIVES_FOUND))
+
+ val assemblers = mutableListOf<DirectiveAssembler>()
+ var assembler = DirectiveAssembler()
+ val linebreakPattern = Arara.config[AraraSpec.Directive
+ .linebreakPattern].toPattern()
+ for ((lineno, content) in pairs) {
+ val linebreakMatcher = linebreakPattern.matcher(content)
+ if (linebreakMatcher.find()) {
+ if (!assembler.isAppendAllowed) {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_VALIDATE_ORPHAN_LINEBREAK,
+ lineno
+ )
+ )
+ } else {
+ assembler.addLineNumber(lineno)
+ assembler.appendLine(linebreakMatcher.group(1))
+ }
+ } else {
+ if (assembler.isAppendAllowed) {
+ assemblers.add(assembler)
+ }
+ assembler = DirectiveAssembler()
+ assembler.addLineNumber(lineno)
+ assembler.appendLine(content)
+ }
+ }
+ if (assembler.isAppendAllowed) {
+ assemblers.add(assembler)
+ }
+
+ return assemblers.map { generateDirective(it) }
+ }
+
+ /**
+ * Generates a directive from a directive assembler.
+ *
+ * @param assembler The directive assembler.
+ * @return The corresponding directive.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ @Suppress("MagicNumber")
+ fun generateDirective(assembler: DirectiveAssembler): Directive {
+ val matcher = Arara.config[AraraSpec.Directive.directivePattern]
+ .toPattern().matcher(assembler.getText())
+ if (matcher.find()) {
+ val directive = Directive(
+ identifier = matcher.group(1)!!,
+ parameters = getParameters(matcher.group(3),
+ assembler.getLineNumbers()),
+ conditional = Conditional(
+ type = getType(matcher.group(5)),
+ condition = matcher.group(6) ?: ""
+ ),
+ lineNumbers = assembler.getLineNumbers()
+ )
+
+ logger.info(messages.getMessage(
+ Messages.LOG_INFO_POTENTIAL_DIRECTIVE_FOUND, directive))
+
+ return directive
+ } else {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_VALIDATE_INVALID_DIRECTIVE_FORMAT,
+ "(" + assembler.getLineNumbers()
+ .joinToString(", ") + ")"
+ )
+ )
+ }
+ }
+
+ /**
+ * Gets the conditional type based on the input string.
+ *
+ * @param text The input string.
+ * @return The conditional type.
+ */
+ private fun getType(text: String?): Conditional.ConditionalType {
+ return when (text) {
+ null -> Conditional.ConditionalType.NONE
+ "if" -> Conditional.ConditionalType.IF
+ "while" -> Conditional.ConditionalType.WHILE
+ "until" -> Conditional.ConditionalType.UNTIL
+ else -> Conditional.ConditionalType.UNLESS
+ }
+ }
+
+ /**
+ * Gets the parameters from the input string.
+ *
+ * @param text The input string.
+ * @param numbers The list of line numbers.
+ * @return A map containing the directive parameters.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ private fun getParameters(
+ text: String?,
+ numbers: List<Int>
+ ): Map<String, Any> {
+ if (text == null)
+ return mapOf()
+
+ /* Before using kotlinx.serialization, there has been a dedicated
+ * directive resolver which instructed SnakeYAML to do the following:
+ *
+ * addImplicitResolver(Tag.MERGE, MERGE, "<")
+ * addImplicitResolver(Tag.NULL, NULL, "~nN\u0000")
+ * addImplicitResolver(Tag.NULL, EMPTY, null)
+ *
+ * This has been removed.
+ */
+ return ObjectMapper(YAMLFactory()).registerKotlinModule().runCatching {
+ readValue<Map<String, Any>>(text)
+ }.getOrElse {
+ throw AraraException(messages.getMessage(
+ Messages.ERROR_VALIDATE_YAML_EXCEPTION,
+ "(" + numbers.joinToString(", ") + ")"),
+ it)
+ }
+ }
+
+ /**
+ * Replicate a directive for given files.
+ *
+ * @param holder The list of files.
+ * @param parameters The parameters for the directive.
+ * @param directive The directive to clone.
+ * @return List of cloned directives.
+ * @throws AraraException If there is an error validating the [holder]
+ * object.
+ */
+ @Throws(AraraException::class)
+ private fun replicateDirective(
+ holder: Any,
+ parameters: Map<String, Any>,
+ directive: Directive
+ ): List<Directive> {
+ return if (holder is List<*>) {
+ // we received a file list, so we map that list to files
+ holder.filterIsInstance<Any>()
+ .asSequence()
+ .map { File(it.toString()) }
+ .map(FileHandlingUtils::getCanonicalFile)
+ // and because we want directives, we replicate our
+ // directive to be applied to that file
+ .map { reference ->
+ directive.copy(parameters = parameters
+ .plus("reference" to reference))
+ }
+ .toList()
+ // we take the result if and only if we have at least one
+ // file and we did not filter out any invalid argument
+ .takeIf { it.isNotEmpty() && holder.size == it.size }
+ // TODO: check exception according to condition
+ ?: throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_VALIDATE_EMPTY_FILES_LIST,
+ "(" + directive.lineNumbers
+ .joinToString(", ") + ")"
+ )
+ )
+ } else {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_VALIDATE_FILES_IS_NOT_A_LIST,
+ "(" + directive.lineNumbers.joinToString(", ") + ")"
+ )
+ )
+ }
+ }
+
+ /**
+ * Validates the list of directives, returning a new list.
+ *
+ * @param directives The list of directives.
+ * @return A new list of directives.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun process(directives: List<Directive>): List<Directive> {
+ val result = mutableListOf<Directive>()
+ directives.forEach { directive ->
+ val parameters = directive.parameters
+
+ if (parameters.containsKey("reference"))
+ throw AraraException(messages.getMessage(
+ Messages.ERROR_VALIDATE_REFERENCE_IS_RESERVED,
+ "(" + directive.lineNumbers.joinToString(", ") + ")"))
+
+ if (parameters.containsKey("files")) {
+ result.addAll(replicateDirective(parameters.getValue("files"),
+ parameters.minus("files"), directive))
+ } else {
+ result.add(directive.copy(parameters = parameters
+ .plus("reference" to
+ Arara.config[AraraSpec.Execution.reference])))
+ }
+ }
+
+ logger.info(messages.getMessage(
+ Messages.LOG_INFO_VALIDATED_DIRECTIVES))
+ logger.info(DisplayUtils.displayOutputSeparator(
+ messages.getMessage(Messages.LOG_INFO_DIRECTIVES_BLOCK)))
+ result.forEach { logger.info(it.toString()) }
+ logger.info(DisplayUtils.displaySeparator())
+
+ return result
+ }
+
+ /**
+ * Checks if the provided line contains the corresponding pattern, based on
+ * the file type, or an empty line.
+ *
+ * @param pattern Pattern to be matched, based on the file type.
+ * @param line Provided line.
+ * @return Logical value indicating if the provided line contains the
+ * corresponding pattern, based on the file type, or an empty line.
+ */
+ private fun checkLinePattern(pattern: Pattern, line: String): Boolean {
+ return line.isBlank() || pattern.matcher(line).find()
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Rule.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Rule.kt
new file mode 100644
index 0000000000..53ac2aa8b1
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/Rule.kt
@@ -0,0 +1,48 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.ruleset
+
+import kotlinx.serialization.Serializable
+import org.islandoftex.arara.utils.CommonUtils
+
+/**
+ * Implements the rule model.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+@Serializable
+class Rule {
+ /**
+ * The rule identifier
+ */
+ var identifier: String = INVALID_RULE_IDENTIFIER
+ get() = CommonUtils.removeKeywordNotNull(field)
+
+ /**
+ * The rule name
+ */
+ var name: String = INVALID_RULE_NAME
+ get() = CommonUtils.removeKeywordNotNull(field)
+
+ /**
+ * The list of authors
+ */
+ var authors: List<String> = listOf()
+ get() = field.mapNotNull { CommonUtils.removeKeyword(it) }
+
+ /**
+ * The list of commands
+ */
+ var commands: List<RuleCommand> = listOf()
+
+ /**
+ * The list of arguments
+ */
+ var arguments: List<Argument> = listOf()
+
+ companion object {
+ const val INVALID_RULE_IDENTIFIER = "INVALID_RULE"
+ const val INVALID_RULE_NAME = "INVALID_RULE"
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/RuleCommand.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/RuleCommand.kt
new file mode 100644
index 0000000000..901718d412
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/RuleCommand.kt
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.ruleset
+
+import kotlinx.serialization.Serializable
+import org.islandoftex.arara.utils.CommonUtils
+
+/**
+ * Implements the rule command model.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+@Serializable
+class RuleCommand {
+ /**
+ * The command name
+ */
+ var name: String? = null
+ get() = CommonUtils.removeKeyword(field)
+
+ /**
+ * The command instruction
+ */
+ var command: String? = null
+ get() = CommonUtils.removeKeyword(field)
+
+ /**
+ * The exit status expression
+ */
+ var exit: String? = null
+ get() = CommonUtils.removeKeyword(field)
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/RuleUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/RuleUtils.kt
new file mode 100644
index 0000000000..e823e4e598
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/ruleset/RuleUtils.kt
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.ruleset
+
+import com.charleskorn.kaml.Yaml
+import java.io.File
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.utils.CommonUtils
+
+/**
+ * Implements rule utilitary methods.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object RuleUtils {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ /**
+ * Parses the provided file, checks the identifier and returns a rule
+ * representation.
+ *
+ * @param file The rule file.
+ * @param identifier The directive identifier.
+ * @return The rule object.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun parseRule(file: File, identifier: String): Rule {
+ val rule = file.runCatching {
+ val text = readText()
+ if (!text.startsWith("!config"))
+ throw Exception("Rule should start with !config")
+ Yaml.default.parse(Rule.serializer(), text)
+ }.getOrElse {
+ throw AraraException(
+ CommonUtils.ruleErrorHeader + messages.getMessage(
+ Messages.ERROR_PARSERULE_GENERIC_ERROR
+ ), it)
+ }
+
+ validateHeader(rule, identifier)
+ validateBody(rule)
+ return rule
+ }
+
+ /**
+ * Validates the rule header according to the directive identifier.
+ *
+ * @param rule The rule object.
+ * @param identifier The directive identifier.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ @Suppress("ThrowsCount")
+ private fun validateHeader(rule: Rule, identifier: String) {
+ if (rule.identifier != Rule.INVALID_RULE_IDENTIFIER) {
+ if (rule.identifier != identifier) {
+ throw AraraException(CommonUtils.ruleErrorHeader +
+ messages.getMessage(
+ Messages.ERROR_VALIDATEHEADER_WRONG_IDENTIFIER,
+ rule.identifier,
+ identifier))
+ }
+ } else {
+ throw AraraException(CommonUtils.ruleErrorHeader +
+ messages.getMessage(Messages.ERROR_VALIDATEHEADER_NULL_ID))
+ }
+ if (rule.name == Rule.INVALID_RULE_NAME) {
+ throw AraraException(
+ CommonUtils.ruleErrorHeader + messages.getMessage(
+ Messages.ERROR_VALIDATEHEADER_NULL_NAME
+ )
+ )
+ }
+ }
+
+ /**
+ * Validates the rule body.
+ *
+ * @param rule The rule object.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ @Suppress("ThrowsCount")
+ private fun validateBody(rule: Rule) {
+ if (rule.commands.any { it.command == null }) {
+ throw AraraException(CommonUtils.ruleErrorHeader +
+ messages.getMessage(
+ Messages.ERROR_VALIDATEBODY_NULL_COMMAND))
+ }
+
+ val arguments = mutableListOf<String>()
+ for (argument in rule.arguments) {
+ if (argument.identifier != null) {
+ if (argument.flag != null || argument.default != null) {
+ arguments.add(argument.identifier!!)
+ } else {
+ throw AraraException(
+ CommonUtils.ruleErrorHeader + messages.getMessage(
+ Messages.ERROR_VALIDATEBODY_MISSING_KEYS
+ )
+ )
+ }
+ } else {
+ throw AraraException(
+ CommonUtils.ruleErrorHeader + messages.getMessage(
+ Messages.ERROR_VALIDATEBODY_NULL_ARGUMENT_ID
+ )
+ )
+ }
+ }
+
+ arguments.intersect(listOf("files", "reference")).forEach {
+ throw AraraException(
+ CommonUtils.ruleErrorHeader + messages.getMessage(
+ Messages.ERROR_VALIDATEBODY_ARGUMENT_ID_IS_RESERVED,
+ it
+ )
+ )
+ }
+
+ val expected = arguments.size
+ val found = arguments.toSet().size
+ if (expected != found) {
+ throw AraraException(
+ CommonUtils.ruleErrorHeader + messages.getMessage(
+ Messages.ERROR_VALIDATEBODY_DUPLICATE_ARGUMENT_IDENTIFIERS
+ )
+ )
+ }
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/ClassLoadingUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/ClassLoadingUtils.kt
new file mode 100644
index 0000000000..05a1031a8a
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/ClassLoadingUtils.kt
@@ -0,0 +1,121 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.utils
+
+import java.io.File
+import java.lang.reflect.InvocationTargetException
+import java.net.MalformedURLException
+import java.net.URLClassLoader
+
+/**
+ * Implements utilitary methods for classloading and object instantiation.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object ClassLoadingUtils {
+ /**
+ * Indicator of success or failure of class loading.
+ */
+ enum class ClassLoadingStatus {
+ SUCCESS,
+ FILE_NOT_FOUND,
+ MALFORMED_URL,
+ CLASS_NOT_FOUND,
+ ILLEGAL_ACCESS,
+ INSTANTIATION_EXCEPTION
+ }
+
+ /**
+ * Loads a class from the provided file, potentially a Java archive.
+ * @param file File containing the Java bytecode (namely, a JAR).
+ * @param name The canonical name of the class.
+ * @return A pair representing the status and the class.
+ */
+ fun loadClass(file: File, name: String):
+ Pair<ClassLoadingStatus, Class<*>> {
+ // status and class to be returned,
+ // it defaults to an object class
+ var value: Class<*> = Any::class.java
+
+ // if file does not exist, nothing
+ // can be done, status is changed
+ val status = if (!file.exists()) {
+ ClassLoadingStatus.FILE_NOT_FOUND
+ } else {
+ // classloading involves defining
+ // a classloader and fetching the
+ // desired class from it, based on
+ // the provided file archive
+ try {
+ // creates a new classloader with
+ // the provided file (potentially
+ // a JAR file)
+ val classloader = URLClassLoader(arrayOf(file.toURI().toURL()),
+ ClassLoadingUtils::class.java.classLoader)
+
+ // fetches the class from the
+ // instantiated classloader
+ value = Class.forName(name, true, classloader)
+ ClassLoadingStatus.SUCCESS
+ } catch (_: MalformedURLException) {
+ ClassLoadingStatus.MALFORMED_URL
+ } catch (_: ClassNotFoundException) {
+ ClassLoadingStatus.CLASS_NOT_FOUND
+ }
+ }
+
+ // return a new pair based on the
+ // current status and class holder
+ return status to value
+ }
+
+ /**
+ * Loads a class from the provided file, instantiating it.
+ * @param file File containing the Java bytecode (namely, a JAR).
+ * @param name The canonical name of the class.
+ * @return A pair representing the status and the class object.
+ */
+ fun loadObject(file: File, name: String): Pair<ClassLoadingStatus, Any> {
+ // load the corresponding class
+ // based on the qualified name
+ val pair = loadClass(file, name)
+
+ // status and object to be returned,
+ // it defaults to an object
+ var status = pair.first
+ var value = Any()
+
+ // checks if the class actually
+ // exists, otherwise simply
+ // ignore instantiation
+ if (status == ClassLoadingStatus.SUCCESS) {
+ // object instantiation relies
+ // on the default constructor
+ // (without arguments), class
+ // must implement it
+
+ // OBS: constructors with arguments
+ // must be invoked through reflection
+ try {
+ // get the class reference from
+ // the pair and instantiate it
+ // by invoking the default
+ // constructor (without arguments)
+ value = pair.second.getDeclaredConstructor().newInstance()
+ } catch (_: IllegalAccessException) {
+ status = ClassLoadingStatus.ILLEGAL_ACCESS
+ } catch (_: InstantiationException) {
+ // the user wanted to instantiate an abstract class
+ status = ClassLoadingStatus.INSTANTIATION_EXCEPTION
+ } catch (_: InvocationTargetException) {
+ // the underlying constructor caused an exception
+ status = ClassLoadingStatus.INSTANTIATION_EXCEPTION
+ }
+ }
+
+ // return a new pair based on the
+ // current status and object holder
+ return status to value
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/CommonUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/CommonUtils.kt
new file mode 100644
index 0000000000..6887cd4e84
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/CommonUtils.kt
@@ -0,0 +1,422 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.utils
+
+import java.io.File
+import java.io.IOException
+import java.util.MissingFormatArgumentException
+import java.util.regex.Pattern
+import kotlin.math.ln
+import kotlin.math.pow
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.filehandling.FileHandlingUtils
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.ruleset.Argument
+
+/**
+ * Implements common utilitary methods.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object CommonUtils {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ /**
+ * Gets the list of file types as string, in order.
+ *
+ * @return A string representation of the list of file types, in order.
+ */
+ val fileTypesList: String
+ get() = "[ " + Arara.config[AraraSpec.Execution.fileTypes]
+ .joinToString(" | ") + " ]"
+
+ /**
+ * Gets the rule error header, containing the identifier and the path, if
+ * any.
+ *
+ * @return A string representation of the rule error header, containing the
+ * identifier and the path, if any.
+ */
+ val ruleErrorHeader: String
+ get() {
+ return if (Arara.config[AraraSpec.Execution.InfoSpec.ruleId] != null &&
+ Arara.config[AraraSpec.Execution.InfoSpec.rulePath] != null) {
+ val id = Arara.config[AraraSpec.Execution.InfoSpec.ruleId]!!
+ val path = Arara.config[AraraSpec.Execution.InfoSpec.rulePath]!!
+ messages.getMessage(
+ Messages.ERROR_RULE_IDENTIFIER_AND_PATH,
+ id,
+ path
+ ) + " "
+ } else {
+ ""
+ }
+ }
+
+ /**
+ * Gets a list of all rule paths.
+ *
+ * @return A list of all rule paths.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ val allRulePaths: List<String>
+ @Throws(AraraException::class)
+ get() = Arara.config[AraraSpec.Execution.rulePaths].map {
+ val location = File(InterpreterUtils.construct(it, "quack"))
+ FileHandlingUtils.getParentCanonicalPath(location)
+ }
+
+ /**
+ * Returns the exit status of the application.
+ *
+ * @return An integer representing the exit status of the application.
+ */
+ val exitStatus: Int
+ get() = Arara.config[AraraSpec.Execution.status]
+
+ /**
+ * Gets the preamble content, converting a single string into a list of
+ * strings, based on new lines.
+ *
+ * @return A list of strings representing the preamble content.
+ */
+ val preambleContent: List<String>
+ get() = if (Arara.config[AraraSpec.Execution.preamblesActive]) {
+ Arara.config[AraraSpec.Execution.preamblesContent]
+ .split("\n")
+ .dropLastWhile { it.isEmpty() }
+ .toList()
+ } else {
+ listOf()
+ }
+
+ /**
+ * Checks if the input string is equal to a valid boolean value.
+ *
+ * @param value The input string.
+ * @return A boolean value represented by the provided string.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun checkBoolean(value: String): Boolean {
+ val yes = listOf("yes", "true", "1", "on")
+ val no = listOf("no", "false", "0", "off")
+ return if (!yes.union(no).contains(value.toLowerCase())) {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_CHECKBOOLEAN_NOT_VALID_BOOLEAN,
+ value
+ )
+ )
+ } else {
+ yes.contains(value.toLowerCase())
+ }
+ }
+
+ /**
+ * Removes the keyword from the beginning of the provided string.
+ *
+ * @param line A string to be analyzed.
+ * @return The provided string without the keyword.
+ */
+ fun removeKeyword(line: String?): String? {
+ return if (line == null) null
+ else removeKeywordNotNull(line)
+ }
+
+ /**
+ * Removes the keyword from the beginning of the provided string.
+ *
+ * @param line A string to be analyzed.
+ * @return The provided string without the keyword.
+ */
+ fun removeKeywordNotNull(line: String): String {
+ var tempLine = line
+ val pattern = "^(\\s)*<arara>\\s".toPattern()
+ val matcher = pattern.matcher(tempLine)
+ if (matcher.find()) {
+ tempLine = tempLine.substring(matcher.end())
+ }
+ return tempLine.trim()
+ }
+
+ /**
+ * Flattens a potential list of lists into a list of objects.
+ *
+ * @param list The list to be flattened.
+ * @return The flattened list.
+ */
+ // TODO: check nullity
+ fun flatten(list: List<*>): List<Any> {
+ val result = mutableListOf<Any>()
+ list.forEach { item ->
+ if (item is List<*>)
+ result.addAll(flatten(item))
+ else
+ result.add(item as Any)
+ }
+ return result
+ }
+
+ /**
+ * Gets a set of strings containing unknown keys from a map and a list. It
+ * is a set difference from the keys in the map and the entries in the list.
+ *
+ * @param parameters The map of parameters.
+ * @param arguments The list of arguments.
+ * @return A set of strings representing unknown keys from a map and a list.
+ */
+ fun getUnknownKeys(
+ parameters: Map<String, Any>,
+ arguments: List<Argument>
+ ): Set<String> {
+ val found = parameters.keys
+ val expected = arguments.mapNotNull { it.identifier }
+ return found.subtract(expected)
+ }
+
+ /**
+ * Gets a human readable representation of a size.
+ *
+ * @param size The byte size to be converted.
+ * @return A string representation of the size.
+ */
+ @Suppress("MagicNumber")
+ fun byteSizeToString(size: Long): String {
+ val language = Arara.config[AraraSpec.Execution.language]
+ val conversionFactor = 1000.0
+ return if (size < conversionFactor) "$size B"
+ else
+ (ln(size.toDouble()) / ln(conversionFactor)).toInt().let { exp ->
+ "%.1f %sB".format(language.locale,
+ size / conversionFactor.pow(exp.toDouble()),
+ "kMGTPE"[exp - 1])
+ }
+ }
+
+ /**
+ * Generates a string based on a list of objects, separating each one of
+ * them by one space.
+ *
+ * @param objects A list of objects.
+ * @return A string based on the list of objects, separating each one of
+ * them by one space. Empty values are not considered.
+ */
+ fun generateString(vararg objects: Any): String = objects
+ .map { it.toString() }.filter { it.isNotEmpty() }
+ .joinToString(" ")
+
+ /**
+ * Checks if the file based on the provided extension contains the provided
+ * regex.
+ *
+ * @param extension The file extension.
+ * @param regex The regex.
+ * @return A boolean value indicating if the file contains the provided
+ * regex.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun checkRegex(extension: String, regex: String): Boolean {
+ val file = File(FileHandlingUtils.getPath(extension))
+ return checkRegex(file, regex)
+ }
+
+ /**
+ * Checks if the file contains the provided regex.
+ *
+ * As we use [File.readText] this should not be called on files > 2GB.
+ *
+ * @param file The file.
+ * @param regex The regex.
+ * @return A boolean value indicating if the file contains the provided
+ * regex.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun checkRegex(file: File, regex: String): Boolean {
+ try {
+ val text = file.readText()
+ val pattern = Pattern.compile(regex)
+ val matcher = pattern.matcher(text)
+ return matcher.find()
+ } catch (exception: IOException) {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_CHECKREGEX_IO_EXCEPTION,
+ file.name
+ ),
+ exception
+ )
+ }
+ }
+
+ /**
+ * Replicates a string pattern based on a list of objects, generating a list
+ * as result.
+ *
+ * @param pattern The string pattern.
+ * @param values The list of objects to be merged with the pattern.
+ * @return A list containing the string pattern replicated to each object
+ * from the list.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun replicateList(
+ pattern: String,
+ values: List<Any>
+ ): List<Any> {
+ return try {
+ values.map { String.format(pattern, it) }
+ } catch (exception: MissingFormatArgumentException) {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_REPLICATELIST_MISSING_FORMAT_ARGUMENTS_EXCEPTION
+ ),
+ exception
+ )
+ }
+ }
+
+ /**
+ * Checks if the provided operating system string holds according to the
+ * underlying operating system.
+ *
+ * Supported operating systems:
+ *
+ * * Windows
+ * * Linux
+ * * Mac OS X
+ * * Unix (Linux || Mac OS)
+ * * Cygwin
+ *
+ * @param value A string representing an operating system.
+ * @return A boolean value indicating if the provided string refers to the
+ * underlying operating system.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun checkOS(value: String): Boolean {
+ fun checkOSProperty(key: String): Boolean =
+ getSystemPropertyOrNull("os.name")
+ ?.toLowerCase()?.startsWith(key.toLowerCase()) ?: false
+
+ val values = mutableMapOf<String, Boolean>()
+ values["windows"] = checkOSProperty("Windows")
+ values["linux"] = checkOSProperty("Linux")
+ values["mac"] = checkOSProperty("Mac OS X")
+ values["unix"] = checkOSProperty("Mac OS X") ||
+ checkOSProperty("Linux")
+ values["cygwin"] = SystemCallUtils["cygwin"] as Boolean
+ if (!values.containsKey(value.toLowerCase())) {
+ throw AraraException(
+ messages.getMessage(
+ Messages.ERROR_CHECKOS_INVALID_OPERATING_SYSTEM,
+ value
+ )
+ )
+ }
+ // will never throw, see check above
+ return values.getValue(value.toLowerCase())
+ }
+
+ /**
+ * Gets the system property according to the provided key, or resort to the
+ * fallback value if an exception is thrown or if the key is invalid.
+ *
+ * @param key The system property key.
+ * @param fallback The fallback value.
+ * @return A string containing the system property value or the fallback.
+ */
+ fun getSystemProperty(key: String, fallback: String): String =
+ System.getProperties().runCatching {
+ getOrDefault(key, fallback).toString().takeIf { it != "" }
+ }.getOrNull() ?: fallback
+
+ /**
+ * Access a system property.
+ *
+ * @param key The key of the property.
+ * @return The value of the system property or null if there is an
+ * exception.
+ */
+ fun getSystemPropertyOrNull(key: String): String? =
+ System.getProperties().runCatching { getValue(key).toString() }
+ .getOrNull()
+
+ /**
+ * Generates a list of filenames from the provided command based on a list
+ * of extensions for each underlying operating system.
+ *
+ * @param command A string representing the command.
+ * @return A list of filenames.
+ */
+ private fun appendExtensions(command: String): List<String> {
+ // list of extensions, specific for
+ // each operating system (in fact, it
+ // is more Windows specific)
+ val extensions = if (checkOS("windows")) {
+ // the application is running on
+ // Windows, so let's look for the
+ // following extensions in order
+
+ // this list is actually a sublist from
+ // the original Windows PATHEXT environment
+ // variable which holds the list of executable
+ // extensions that Windows supports
+ listOf(".com", ".exe", ".bat", ".cmd")
+ } else {
+ // no Windows, so the default
+ // extension will be just an
+ // empty string
+ listOf("")
+ }
+
+ // return the resulting list holding the
+ // filenames generated from the
+ // provided command
+ return extensions.map { "$command$it" }
+ }
+
+ /**
+ * Checks if the provided command name is reachable from the system path.
+ *
+ * @param command A string representing the command.
+ * @return A logic value.
+ */
+ fun isOnPath(command: String): Boolean {
+ // first and foremost, let's build the list
+ // of filenames based on the underlying
+ // operating system
+ val filenames = appendExtensions(command)
+ return kotlin.runCatching {
+ // break the path into several parts
+ // based on the path separator symbol
+ System.getenv("PATH").split(File.pathSeparator)
+ .asSequence()
+ .mapNotNull { File(it).listFiles() }
+ // if the search does not return an empty
+ // list, one of the filenames got a match,
+ // and the command is available somewhere
+ // in the system path
+ .firstOrNull {
+ it.any { file ->
+ filenames.contains(file.name) && !file.isDirectory
+ }
+ }?.let { true }
+ }.getOrNull() ?: false
+ // otherwise (and in case of an exception) it is not in the path
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/DisplayUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/DisplayUtils.kt
new file mode 100644
index 0000000000..213a9a21c3
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/DisplayUtils.kt
@@ -0,0 +1,415 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.utils
+
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.configuration.ConfigurationUtils
+import org.islandoftex.arara.filehandling.FileHandlingUtils
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.ruleset.Conditional
+import org.slf4j.LoggerFactory
+
+/**
+ * Implements display utilitary methods.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object DisplayUtils {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ // get the logger context from a factory
+ private val logger = LoggerFactory.getLogger(DisplayUtils::class.java)
+
+ /**
+ * The length of the longest result match as integer.
+ */
+ private val longestMatch: Int = listOf(
+ messages.getMessage(Messages.INFO_LABEL_ON_SUCCESS),
+ messages.getMessage(Messages.INFO_LABEL_ON_FAILURE),
+ messages.getMessage(Messages.INFO_LABEL_ON_ERROR))
+ .map { it.length }.max()!!
+ /**
+ * If the longest match is longer than the width, then it will be truncated
+ * to this length.
+ */
+ private const val shortenedLongestMatch = 10
+
+ /**
+ * The default terminal width defined in the settings.
+ */
+ private val width: Int
+ get() = Arara.config[AraraSpec.Application.width]
+
+ /**
+ * Checks if the execution is in dry-run mode.
+ */
+ private val isDryRunMode: Boolean
+ get() = Arara.config[AraraSpec.Execution.dryrun]
+
+ /**
+ * Checks if the execution is in verbose mode.
+ */
+ private val isVerboseMode: Boolean
+ get() = Arara.config[AraraSpec.Execution.verbose]
+
+ /**
+ * The application path.
+ */
+ private val applicationPath: String
+ get() = try {
+ ConfigurationUtils.applicationPath.toString()
+ } catch (ae: AraraException) {
+ "[unknown application path]"
+ }
+
+ /**
+ * Displays the short version of the current entry in the terminal.
+ *
+ * @param name Rule name.
+ * @param task Task name.
+ */
+ private fun buildShortEntry(name: String, task: String) {
+ val result = if (longestMatch >= width)
+ shortenedLongestMatch
+ else
+ longestMatch
+ val space = width - result - 1
+ val line = "($name) $task ".abbreviate(space - "... ".length)
+ print(line.padEnd(space, '.') + " ")
+ }
+
+ /**
+ * Displays the short version of the current entry result in the terminal.
+ *
+ * @param value The boolean value to be displayed.
+ */
+ private fun buildShortResult(value: Boolean) {
+ val result = longestMatch
+ println(getResult(value).padStart(result))
+ }
+
+ /**
+ * Displays the current entry result in the terminal.
+ *
+ * @param value The boolean value to be displayed.
+ */
+ fun printEntryResult(value: Boolean) {
+ Arara.config[AraraSpec.UserInteraction.displayLine] = false
+ Arara.config[AraraSpec.UserInteraction.displayResult] = true
+ Arara.config[AraraSpec.Execution.status] = if (value) 0 else 1
+ logger.info(
+ messages.getMessage(
+ Messages.LOG_INFO_TASK_RESULT
+ ) + " " + getResult(value)
+ )
+ if (!isDryRunMode) {
+ if (!isVerboseMode) {
+ buildShortResult(value)
+ } else {
+ buildLongResult(value)
+ }
+ }
+ }
+
+ /**
+ * Displays a long version of the current entry result in the terminal.
+ *
+ * @param value The boolean value to be displayed
+ */
+ private fun buildLongResult(value: Boolean) {
+ val width = width
+ println("\n" + (" " + getResult(value)).padStart(width, '-'))
+ }
+
+ /**
+ * Displays the current entry in the terminal.
+ *
+ * @param name The rule name.
+ * @param task The task name.
+ */
+ fun printEntry(name: String, task: String) {
+ logger.info(
+ messages.getMessage(
+ Messages.LOG_INFO_INTERPRET_TASK,
+ task,
+ name
+ )
+ )
+ Arara.config[AraraSpec.UserInteraction.displayLine] = true
+ Arara.config[AraraSpec.UserInteraction.displayResult] = false
+ if (!isDryRunMode) {
+ if (!isVerboseMode) {
+ buildShortEntry(name, task)
+ } else {
+ buildLongEntry(name, task)
+ }
+ } else {
+ buildDryRunEntry(name, task)
+ }
+ }
+
+ /**
+ * Displays a long version of the current entry in the terminal.
+ *
+ * @param name Rule name.
+ * @param task Task name.
+ */
+ private fun buildLongEntry(name: String, task: String) {
+ if (Arara.config[AraraSpec.UserInteraction.displayRolling]) {
+ addNewLine()
+ } else {
+ Arara.config[AraraSpec.UserInteraction.displayRolling] = true
+ }
+ println(displaySeparator())
+ println("($name) $task".abbreviate(width))
+ println(displaySeparator())
+ }
+
+ /**
+ * Displays a dry-run version of the current entry in the terminal.
+ *
+ * @param name The rule name.
+ * @param task The task name.
+ */
+ private fun buildDryRunEntry(name: String, task: String) {
+ if (Arara.config[AraraSpec.UserInteraction.displayRolling]) {
+ addNewLine()
+ } else {
+ Arara.config[AraraSpec.UserInteraction.displayRolling] = true
+ }
+ println("[DR] ($name) $task".abbreviate(width))
+ println(displaySeparator())
+ }
+
+ /**
+ * Displays the exception in the terminal.
+ *
+ * @param exception The exception object.
+ */
+ fun printException(exception: AraraException) {
+ Arara.config[AraraSpec.UserInteraction.displayException] = true
+ Arara.config[AraraSpec.Execution.status] = 2
+
+ val display = Arara.config[AraraSpec.UserInteraction.displayLine]
+ if (Arara.config[AraraSpec.UserInteraction.displayResult])
+ addNewLine()
+ if (display) {
+ if (!isDryRunMode) {
+ if (!isVerboseMode) {
+ buildShortError()
+ } else {
+ buildLongError()
+ }
+ addNewLine()
+ }
+ }
+ val text = (if (exception.hasException())
+ exception.message + " " + messages.getMessage(
+ Messages.INFO_DISPLAY_EXCEPTION_MORE_DETAILS)
+ else
+ exception.message) ?: "EXCEPTION PROVIDES NO MESSAGE"
+ // TODO: check null handling
+ logger.error(text)
+ wrapText(text)
+ if (exception.hasException()) {
+ addNewLine()
+ displayDetailsLine()
+ val details = exception.exception!!.message!!
+ logger.error(details)
+ wrapText(details)
+ }
+ }
+
+ /**
+ * Gets the string representation of the provided boolean value.
+ *
+ * @param value The boolean value.
+ * @return The string representation.
+ */
+ private fun getResult(value: Boolean): String {
+ return if (value)
+ messages.getMessage(
+ Messages.INFO_LABEL_ON_SUCCESS
+ )
+ else
+ messages.getMessage(Messages.INFO_LABEL_ON_FAILURE)
+ }
+
+ /**
+ * Displays the short version of an error in the terminal.
+ */
+ private fun buildShortError() {
+ val result = longestMatch
+ println(messages.getMessage(Messages.INFO_LABEL_ON_ERROR)
+ .padStart(result))
+ }
+
+ /**
+ * Displays the long version of an error in the terminal.
+ */
+ private fun buildLongError() {
+ println((" " + messages.getMessage(Messages.INFO_LABEL_ON_ERROR))
+ .padStart(width, '-'))
+ }
+
+ /**
+ * Displays the provided text wrapped nicely according to the default
+ * terminal width.
+ *
+ * @param text The text to be displayed.
+ */
+ fun wrapText(text: String) = println(text.wrap(width))
+
+ /**
+ * Displays the rule authors in the terminal.
+ *
+ * @param authors The list of authors.
+ */
+ fun printAuthors(authors: List<String>) {
+ val line = if (authors.size == 1)
+ messages.getMessage(Messages.INFO_LABEL_AUTHOR)
+ else
+ messages.getMessage(Messages.INFO_LABEL_AUTHORS)
+ val text = if (authors.isEmpty())
+ messages.getMessage(Messages.INFO_LABEL_NO_AUTHORS)
+ else
+ authors.joinToString(", ") { it.trim() }
+ wrapText("$line $text")
+ }
+
+ /**
+ * Displays the current conditional in the terminal.
+ *
+ * @param conditional The conditional object.
+ */
+ fun printConditional(conditional: Conditional) {
+ if (conditional.type !== Conditional.ConditionalType.NONE) {
+ wrapText(messages.getMessage(Messages.INFO_LABEL_CONDITIONAL) +
+ " (" + conditional.type + ") " +
+ conditional.condition)
+ }
+ }
+
+ /**
+ * Displays the file information in the terminal.
+ */
+ fun printFileInformation() {
+ val file = Arara.config[AraraSpec.Execution.reference]
+ val version = Arara.config[AraraSpec.Application.version]
+ val line = messages.getMessage(
+ Messages.INFO_DISPLAY_FILE_INFORMATION,
+ file.name,
+ CommonUtils.byteSizeToString(file.length()),
+ FileHandlingUtils.getLastModifiedInformation(file)
+ )
+ logger.info(messages.getMessage(
+ Messages.LOG_INFO_WELCOME_MESSAGE,
+ version
+ ))
+ logger.info(displaySeparator())
+ logger.debug("::: arara @ $applicationPath")
+ logger.debug("::: Java %s, %s".format(
+ CommonUtils.getSystemProperty("java.version",
+ "[unknown version]"),
+ CommonUtils.getSystemProperty("java.vendor",
+ "[unknown vendor]")
+ ))
+ logger.debug("::: %s".format(
+ CommonUtils.getSystemProperty("java.home",
+ "[unknown location]")
+ ))
+ logger.debug("::: %s, %s, %s".format(
+ CommonUtils.getSystemProperty("os.name",
+ "[unknown OS name]"),
+ CommonUtils.getSystemProperty("os.arch",
+ "[unknown OS arch]"),
+ CommonUtils.getSystemProperty("os.version",
+ "[unknown OS version]")
+ ))
+ logger.debug("::: user.home @ %s".format(
+ CommonUtils.getSystemProperty("user.home",
+ "[unknown user's home directory]")
+ ))
+ logger.debug("::: CF @ %s".format(Arara.config[AraraSpec.Execution
+ .configurationName]))
+ logger.debug(displaySeparator())
+ logger.info(line)
+ wrapText(line)
+ addNewLine()
+ }
+
+ /**
+ * Displays the elapsed time in the terminal.
+ *
+ * @param seconds The elapsed seconds.
+ */
+ fun printTime(seconds: Double) {
+ val language = Arara.config[AraraSpec.Execution.language]
+
+ if (Arara.config[AraraSpec.UserInteraction.displayTime]) {
+ if (Arara.config[AraraSpec.UserInteraction.displayLine] ||
+ Arara.config[AraraSpec.UserInteraction.displayException])
+ addNewLine()
+
+ val text = messages.getMessage(
+ Messages.INFO_DISPLAY_EXECUTION_TIME,
+ "%1.2f".format(language.locale, seconds))
+ logger.info(text)
+ wrapText(text)
+ }
+ }
+
+ /**
+ * Displays the application logo in the terminal.
+ */
+ fun printLogo() {
+ println("""
+ __ _ _ __ __ _ _ __ __ _
+ / _` | '__/ _` | '__/ _` |
+ | (_| | | | (_| | | | (_| |
+ \__,_|_| \__,_|_| \__,_|
+ """.trimIndent())
+ addNewLine()
+ }
+
+ /**
+ * Adds a new line in the terminal.
+ */
+ private fun addNewLine() {
+ println()
+ }
+
+ /**
+ * Displays a line containing details.
+ */
+ private fun displayDetailsLine() {
+ val line = messages.getMessage(
+ Messages.INFO_LABEL_ON_DETAILS) + " "
+ println(line.abbreviate(width).padEnd(width, '-'))
+ }
+
+ /**
+ * Gets the output separator with the provided text.
+ *
+ * @param message The provided text.
+ * @return A string containing the output separator with the provided text.
+ */
+ fun displayOutputSeparator(message: String): String {
+ return " $message ".center(width, '-')
+ }
+
+ /**
+ * Gets the line separator.
+ *
+ * @return A string containing the line separator.
+ */
+ fun displaySeparator(): String {
+ return "-".repeat(width)
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/Extensions.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/Extensions.kt
new file mode 100644
index 0000000000..10ace0b7f1
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/Extensions.kt
@@ -0,0 +1,68 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.utils
+
+import kotlin.math.ceil
+
+/**
+ * Abbreviate a String to a maximal width.
+ *
+ * @param maxWidth The maximal width to truncate to.
+ * @param ellipsis The string to use to indicate an ellipsis.
+ * @throws IllegalArgumentException If the string would consist only of the
+ * ellipsis after shortening.
+ * @return The abbreviated string.
+ */
+@Throws(IllegalArgumentException::class)
+fun String.abbreviate(maxWidth: Int, ellipsis: String = "…"): String {
+ return when {
+ maxWidth < ellipsis.length + 1 ->
+ throw IllegalArgumentException("Can't abbreviate text further")
+ this.length < maxWidth -> this
+ else -> this.substring(0, maxWidth - ellipsis.length) + ellipsis
+ }
+}
+
+/**
+ * Center a string within a specified number of columns.
+ *
+ * This does not center anything if the string is longer than the specified
+ * width.
+ *
+ * @param width The number of columns.
+ * @param padChar The char to pad with.
+ * @return The padded string.
+ */
+fun String.center(width: Int, padChar: Char): String {
+ return if (this.length > width) this
+ else {
+ val charsLeft = width - this.length
+ padChar.toString().repeat(charsLeft / 2) + this +
+ padChar.toString().repeat(ceil(charsLeft.toDouble() / 2.0).toInt())
+ }
+}
+
+/**
+ * Wrap text at a specified width.
+ *
+ * Algorithm from Wikipedia:
+ * https://en.wikipedia.org/wiki/Line_wrap_and_word_wrap#Minimum_number_of_lines
+ *
+ * @param width The width to wrap at.
+ * @return Wrapped text.
+ */
+fun String.wrap(width: Int): String {
+ val words = this.split(" ")
+ var wrapped = words[0]
+ var spaceLeft = width - wrapped.length
+ words.drop(1).forEach {
+ val len = it.length
+ wrapped += if (len + 1 > spaceLeft) {
+ spaceLeft = width - len
+ "\n$it"
+ } else {
+ spaceLeft -= len + 1
+ " $it"
+ }
+ }
+ return wrapped
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/InterpreterUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/InterpreterUtils.kt
new file mode 100644
index 0000000000..00313a7714
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/InterpreterUtils.kt
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.utils
+
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+import java.io.OutputStream
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import kotlin.time.Duration
+import kotlin.time.ExperimentalTime
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.islandoftex.arara.localization.LanguageController
+import org.islandoftex.arara.localization.Messages
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.ruleset.Command
+import org.islandoftex.arara.ruleset.Conditional
+import org.slf4j.LoggerFactory
+import org.zeroturnaround.exec.InvalidExitValueException
+import org.zeroturnaround.exec.ProcessExecutor
+import org.zeroturnaround.exec.listener.ShutdownHookProcessDestroyer
+
+/**
+ * Implements interpreter utilitary methods.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object InterpreterUtils {
+ // the application messages obtained from the
+ // language controller
+ private val messages = LanguageController
+
+ // get the logger context from a factory
+ private val logger = LoggerFactory.getLogger(InterpreterUtils::class.java)
+
+ /**
+ * Checks if the current conditional has a prior evaluation.
+ *
+ * @param conditional The current conditional object.
+ * @return A boolean value indicating if the current conditional has a prior
+ * evaluation.
+ */
+ fun runPriorEvaluation(conditional: Conditional): Boolean {
+ return if (Arara.config[AraraSpec.Execution.dryrun]) {
+ false
+ } else {
+ when (conditional.type) {
+ Conditional.ConditionalType.IF,
+ Conditional.ConditionalType.WHILE,
+ Conditional.ConditionalType.UNLESS -> true
+ else -> false
+ }
+ }
+ }
+
+ @ExperimentalTime
+ private fun getProcessExecutorForCommand(
+ command: Command,
+ buffer: OutputStream
+ ):
+ ProcessExecutor {
+ val timeOutValue = Arara.config[AraraSpec.Execution.timeoutValue]
+ var executor = ProcessExecutor().command((command).elements)
+ .directory(command.workingDirectory.absoluteFile)
+ .addDestroyer(ShutdownHookProcessDestroyer())
+ if (Arara.config[AraraSpec.Execution.timeout]) {
+ if (timeOutValue == Duration.ZERO) {
+ throw AraraException(messages.getMessage(Messages
+ .ERROR_RUN_TIMEOUT_INVALID_RANGE))
+ }
+ executor = executor.timeout(timeOutValue.toLongNanoseconds(),
+ TimeUnit.NANOSECONDS)
+ }
+ val tee = if (Arara.config[AraraSpec.Execution.verbose]) {
+ executor = executor.redirectInput(System.`in`)
+ TeeOutputStream(System.out, buffer)
+ } else {
+ TeeOutputStream(buffer)
+ }
+ executor = executor.redirectOutput(tee).redirectError(tee)
+ return executor
+ }
+
+ /**
+ * Runs the command in the underlying operating system.
+ *
+ * @param command An object representing the command.
+ * @return An integer value representing the exit code.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @ExperimentalTime
+ @Throws(AraraException::class)
+ fun run(command: Command): Int {
+ val buffer = ByteArrayOutputStream()
+ val executor = getProcessExecutorForCommand(command, buffer)
+ return executor.runCatching {
+ val exit = execute().exitValue
+ logger.info(DisplayUtils.displayOutputSeparator(
+ messages.getMessage(Messages.LOG_INFO_BEGIN_BUFFER)))
+ logger.info(buffer.toString())
+ logger.info(DisplayUtils.displayOutputSeparator(
+ messages.getMessage(Messages.LOG_INFO_END_BUFFER)))
+ exit
+ }.getOrElse {
+ throw AraraException(messages.getMessage(
+ when (it) {
+ is IOException -> Messages.ERROR_RUN_IO_EXCEPTION
+ is InterruptedException ->
+ Messages.ERROR_RUN_INTERRUPTED_EXCEPTION
+ is InvalidExitValueException ->
+ Messages.ERROR_RUN_INVALID_EXIT_VALUE_EXCEPTION
+ is TimeoutException ->
+ Messages.ERROR_RUN_TIMEOUT_EXCEPTION
+ else -> Messages.ERROR_RUN_GENERIC_EXCEPTION
+ }), it)
+ }
+ }
+
+ /**
+ * Builds the rule path based on the rule name and returns the corresponding
+ * file location.
+ *
+ * @param name The rule name.
+ * @return The rule file.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun buildRulePath(name: String): File? {
+ Arara.config[AraraSpec.Execution.rulePaths].forEach { path ->
+ val location = File(construct(path, name))
+ if (location.exists())
+ return location
+ }
+ return null
+ }
+
+ /**
+ * Constructs the path given the current path and the rule name.
+ *
+ * @param path The current path.
+ * @param name The rule name.
+ * @return The constructed path.
+ * @throws AraraException Something wrong happened, to be caught in the
+ * higher levels.
+ */
+ @Throws(AraraException::class)
+ fun construct(path: String, name: String): String {
+ val fileName = "$name.yaml"
+ val location = File(path)
+ return if (location.isAbsolute) {
+ location.resolve(fileName).toString()
+ } else {
+ Arara.config[AraraSpec.Execution.workingDirectory]
+ // first resolve the path (rule path) against the working
+ // directory, then the rule name we want to resolve
+ .resolve(path).resolve(fileName).toAbsolutePath().toString()
+ }
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/LoggingUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/LoggingUtils.kt
new file mode 100644
index 0000000000..f9719fa31f
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/LoggingUtils.kt
@@ -0,0 +1,70 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.utils
+
+import ch.qos.logback.classic.LoggerContext
+import ch.qos.logback.classic.joran.JoranConfigurator
+import ch.qos.logback.core.joran.spi.JoranException
+import java.io.InputStream
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+import org.slf4j.LoggerFactory
+
+/**
+ * Implements the logging controller. This class actually sets the logging
+ * configuration in order to allow appending results to a file.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object LoggingUtils {
+ // configuration resource as an input stream
+ // the configuration is actually a XML file.
+ private val resource: InputStream by lazy {
+ LoggingUtils::class.java
+ .getResourceAsStream("/org/islandoftex/arara/configuration/logback.xml")
+ }
+
+ /**
+ * Sets the logging configuration according to the provided boolean value.
+ * If the value is set to true, the log entries will be appended to a file,
+ * otherwise the logging feature will keep silent.
+ * @param enable A boolean value that indicates the logging behaviour
+ * throughout the application.
+ */
+ fun enableLogging(enable: Boolean) {
+ // get the logger context from a factory, set a
+ // new context and reset it
+ val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext
+
+ try {
+ // get a new configuration and set
+ // the context
+ val configurator = JoranConfigurator()
+ configurator.context = loggerContext
+ loggerContext.reset()
+
+ // if enabled, the log entries will be
+ // appended to a file, otherwise it will
+ // remain silent
+ if (enable) {
+ // set the file name and configure
+ // the logging controller to append
+ // entries to the file
+ val name = Arara.config[AraraSpec.Execution.logName]
+ loggerContext.putProperty("name", name)
+ configurator.doConfigure(resource)
+ }
+ } catch (_: JoranException) {
+ // quack, quack, quack!
+ }
+ }
+
+ /**
+ * Initializes the logging controller by disabling it. I don't want an odd
+ * behaviour out of the box.
+ */
+ fun init() {
+ enableLogging(false)
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/MessageUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/MessageUtils.kt
new file mode 100644
index 0000000000..ae695c44fb
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/MessageUtils.kt
@@ -0,0 +1,266 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.utils
+
+import javax.swing.JOptionPane
+import javax.swing.UIManager
+import org.islandoftex.arara.Arara
+import org.islandoftex.arara.configuration.AraraSpec
+
+/**
+ * Implements utilitary methods for displaying messages.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object MessageUtils {
+ // holds the default width for the
+ // message body, in pixels
+ private const val WIDTH = 250
+
+ // let's start the UI manager and set
+ // the default look and feel to be as
+ // close as possible to the system
+ init {
+ // get the current look and feel
+ var laf = Arara.config[AraraSpec.UserInteraction.lookAndFeel]
+
+ // check if one is actually set
+ if (laf != "none") {
+ // use a special keyword to indicate
+ // the use of a system look and feel
+ if (laf == "system") {
+ laf = UIManager.getSystemLookAndFeelClassName()
+ }
+
+ // let's try it, in case it fails,
+ // rely to the default look and feel
+ try {
+ // get the system look and feel name
+ // and try to set it as default
+ UIManager.setLookAndFeel(laf)
+ } catch (_: Exception) {
+ // quack, quack, quack
+ }
+ }
+ }
+
+ /**
+ * Normalizes the icon type to one of the five available icons.
+ * @param value An integer value.
+ * @return The normalized integer value.
+ */
+ @Suppress("MagicNumber")
+ private fun normalizeIconType(value: Int): Int {
+ // do the normalization according to the available
+ // icons in the underlying message implementation
+ return when (value) {
+ 1 -> JOptionPane.ERROR_MESSAGE
+ 2 -> JOptionPane.INFORMATION_MESSAGE
+ 3 -> JOptionPane.WARNING_MESSAGE
+ 4 -> JOptionPane.QUESTION_MESSAGE
+ else -> JOptionPane.PLAIN_MESSAGE
+ }
+ }
+
+ /**
+ * Normalizes the message width, so only valid nonzero values are accepted.
+ * @param value An integer value corresponding to the message width.
+ * @return The normalized width.
+ */
+ private fun normalizeMessageWidth(value: Int): Int {
+ return if (value > 0) value else WIDTH
+ }
+
+ /**
+ * Shows the message.
+ * @param width Integer value, in pixels.
+ * @param type Type of message.
+ * @param title Title of the message.
+ * @param text Text of the message.
+ */
+ fun showMessage(
+ width: Int,
+ type: Int,
+ title: String,
+ text: String
+ ) {
+ // effectively shows the message based
+ // on the provided parameters
+ JOptionPane.showMessageDialog(null,
+ String.format(
+ "<html><body style=\"width:%dpx\">%s</body></html>",
+ normalizeMessageWidth(width),
+ text),
+ title,
+ normalizeIconType(type)
+ )
+ }
+
+ /**
+ * Shows the message. It relies on the default width.
+ * @param type Type of message.
+ * @param title Title of the message.
+ * @param text Text of the message.
+ */
+ fun showMessage(type: Int, title: String, text: String) {
+ showMessage(WIDTH, type, title, text)
+ }
+
+ /**
+ * Shows a message with options presented as an array of buttons.
+ * @param width Integer value, in pixels.
+ * @param type Type of message.
+ * @param title Title of the message.
+ * @param text Text of the message.
+ * @param buttons An array of objects to be presented as buttons.
+ * @return The index of the selected button, starting from 1.
+ */
+ fun showOptions(
+ width: Int,
+ type: Int,
+ title: String,
+ text: String,
+ vararg buttons: Any
+ ): Int {
+ // returns the index of the selected button,
+ // zero if nothing is selected
+ return JOptionPane.showOptionDialog(null,
+ String.format(
+ "<html><body style=\"width:%dpx\">%s</body></html>",
+ normalizeMessageWidth(width),
+ text),
+ title,
+ JOptionPane.DEFAULT_OPTION,
+ normalizeIconType(type), null,
+ buttons,
+ buttons[0]
+ ) + 1
+ }
+
+ /**
+ * Shows a message with options presented as an array of buttons. It relies
+ * on the default width.
+ * @param type Type of message.
+ * @param title Title of the message.
+ * @param text Text of the message.
+ * @param buttons An array of objects to be presented as buttons.
+ * @return The index of the selected button, starting from 1.
+ */
+ @Suppress("SpreadOperator")
+ fun showOptions(
+ type: Int,
+ title: String,
+ text: String,
+ vararg buttons: Any
+ ): Int {
+ return showOptions(WIDTH, type, title, text, *buttons)
+ }
+
+ /**
+ * Shows a message with a text input.
+ * @param width Integer value, in pixels.
+ * @param type Type of message.
+ * @param title Title of the message.
+ * @param text Text of the message.
+ * @return The string representing the input text.
+ */
+ fun showInput(
+ width: Int,
+ type: Int,
+ title: String,
+ text: String
+ ): String {
+ // get the string from the
+ // input text, if any
+ val input = JOptionPane.showInputDialog(null,
+ String.format(
+ "<html><body style=\"width:%dpx\">%s</body></html>",
+ normalizeMessageWidth(width),
+ text),
+ title,
+ normalizeIconType(type))
+
+ // if the input is not null, that is,
+ // the user actually typed something
+ // return the trimmed string otherwise
+ // an empty string
+ return input?.trim() ?: ""
+ }
+
+ /**
+ * Shows a message with a text input. It relies on the default width.
+ * @param type Type of message.
+ * @param title Title of the message.
+ * @param text Text of the message.
+ * @return The string representing the input text.
+ */
+ fun showInput(type: Int, title: String, text: String): String {
+ return showInput(WIDTH, type, title, text)
+ }
+
+ /**
+ * Shows a message with options presented as a dropdown list of elements.
+ * @param width Integer value, in pixels.
+ * @param type Type of message.
+ * @param title Title of the message.
+ * @param text Text of the message.
+ * @param elements An array of objects representing the elements.
+ * @return The index of the selected element, starting from 1.
+ */
+ fun showDropdown(
+ width: Int,
+ type: Int,
+ title: String,
+ text: String,
+ vararg elements: Any
+ ): Int {
+ // show the dropdown list and get
+ // the selected object, if any
+ val index = JOptionPane.showInputDialog(null,
+ String.format(
+ "<html><body style=\"width:%dpx\">%s</body></html>",
+ normalizeMessageWidth(width),
+ text),
+ title,
+ normalizeIconType(type), null,
+ elements,
+ elements[0])
+
+ // if it's not a null object, let's
+ // find the corresponding index
+ if (index != null) {
+ elements.forEachIndexed { i, value ->
+ // if the element is found, simply
+ // return the index plus 1, as zero
+ // corresponds to no selection at all
+ if (value == index) {
+ return i + 1
+ }
+ }
+ }
+
+ // nothing was selected,
+ // simply return zero
+ return 0
+ }
+
+ /**
+ * Shows a message with options presented as a dropdown list of elements. It
+ * relies on the default width.
+ * @param type Type of message.
+ * @param title Title of the message.
+ * @param text Text of the message.
+ * @param elements An array of objects representing the elements.
+ * @return The index of the selected element, starting from 1.
+ */
+ @Suppress("SpreadOperator")
+ fun showDropdown(
+ type: Int,
+ title: String,
+ text: String,
+ vararg elements: Any
+ ): Int {
+ return showDropdown(WIDTH, type, title, text, *elements)
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/SystemCallUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/SystemCallUtils.kt
new file mode 100644
index 0000000000..9b443736a1
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/SystemCallUtils.kt
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.utils
+
+import org.islandoftex.arara.model.AraraException
+import org.islandoftex.arara.ruleset.Command
+import org.zeroturnaround.exec.ProcessExecutor
+
+/**
+ * Implements a system call controller.
+ *
+ * This class wraps a map that holds the result of system specific variables
+ * not directly available at runtime and makes unsafe calling of system
+ * commands available to rules.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+object SystemCallUtils {
+ /**
+ * When executing a system call goes wrong, this status code is returned.
+ */
+ const val errorExitStatus = -99
+ /**
+ * When executing a system call goes wrong and the caller asked for output,
+ * this output will be returned.
+ */
+ const val errorCommandOutput = ""
+
+ // the system call map which holds the result of
+ // system specific variables not directly available
+ // at runtime; the idea here is to provide wrappers
+ // to the map getter, so it could be easily manipulated
+ // create the new map instance to be
+ // populated on demand
+ private val map: MutableMap<String, Any> = mutableMapOf()
+
+ // the commands map will allow the system call map being
+ // populated only on demand, that is, if the key is not
+ // found, this map will provide the corresponding method
+ // and update the value
+ // create the new map of commands and
+ // add the corresponding system calls
+ private val commands: MutableMap<String, () -> Any> = mutableMapOf(
+ "cygwin" to {
+ // Implements the body of the command. In this particular
+ // instance, it checks if we are inside a Cygwin environment.
+ // Returns a boolean value indicating if we are inside a Cygwin
+ // environment.
+
+ // execute a new system call to 'uname -s', read the output
+ // as an UTF-8 string, lowercase it and check if it starts
+ // with the 'cygwin' string; if so, we are inside Cygwin
+ executeSystemCommand(Command("uname", "-s"))
+ .second.toLowerCase().startsWith("cygwin")
+ })
+
+ /**
+ * Gets the object indexed by the provided key. This method actually holds
+ * the map method of the very same name.
+ *
+ * @param key The provided map key.
+ * @return The object indexed by the provided map key.
+ */
+ @Throws(NoSuchElementException::class, AraraException::class)
+ operator fun get(key: String): Any {
+ // if key is not found, meaning that
+ // the value wasn't required before
+ if (!map.containsKey(key)) {
+ if (commands.containsKey(key))
+ // perform the system call and
+ // populate the corresponding value
+ map[key] = commands[key]!!.invoke()
+ else
+ throw AraraException("The requested key could not be " +
+ "translated into a command to get the call value.")
+ }
+
+ // simply return the corresponding
+ // value based on the provided key
+ return map.getValue(key)
+ }
+
+ /**
+ * Executes a system command from the underlying operating system and
+ * returns a pair containing the exit status and the command output as a
+ * string.
+ * @param command The system command to be executed.
+ * @return A pair containing the exit status and the system command output
+ * as a string.
+ */
+ fun executeSystemCommand(command: Command): Pair<Int, String> {
+ return ProcessExecutor(command.elements).runCatching {
+ directory(command.workingDirectory)
+ readOutput(true)
+ execute().run {
+ exitValue to outputUTF8()
+ }
+ }.getOrElse {
+ // quack, quack, do nothing, just
+ // return a default error code
+ errorExitStatus to errorCommandOutput
+ }
+ }
+}
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/TeeOutputStream.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/TeeOutputStream.kt
new file mode 100644
index 0000000000..dee15176f2
--- /dev/null
+++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/TeeOutputStream.kt
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package org.islandoftex.arara.utils
+
+import java.io.IOException
+import java.io.OutputStream
+
+/**
+ * Implements a stream splitter.
+ *
+ * @author Island of TeX
+ * @version 5.0
+ * @since 4.0
+ */
+class TeeOutputStream(
+ /**
+ * The array of output streams holds every output stream that will be
+ * written to.
+ */
+ vararg outputStreams: OutputStream
+) : OutputStream() {
+ /**
+ * An array of streams in which an object of this class will split data.
+ */
+ private val streams: List<OutputStream> = outputStreams.toList()
+
+ /**
+ * Writes the provided integer to each stream.
+ *
+ * @param b The provided integer
+ * @throws IOException An IO exception.
+ */
+ @Throws(IOException::class)
+ override fun write(b: Int) = streams.forEach { it.write(b) }
+
+ /**
+ * Writes the provided byte array to each stream, with the provided offset
+ * and length.
+ *
+ * @param b The byte array.
+ * @param offset The offset.
+ * @param length The length.
+ * @throws IOException An IO exception.
+ */
+ @Throws(IOException::class)
+ override fun write(b: ByteArray, offset: Int, length: Int) =
+ streams.forEach { it.write(b, offset, length) }
+
+ /**
+ * Flushes every stream.
+ *
+ * @throws IOException An IO exception.
+ */
+ @Throws(IOException::class)
+ override fun flush() = streams.forEach { it.flush() }
+
+ /**
+ * Closes every stream silently.
+ */
+ override fun close() = streams.forEach {
+ try {
+ it.close()
+ } catch (ignored: IOException) {
+ // do nothing on purpose
+ }
+ }
+}