summaryrefslogtreecommitdiff
path: root/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils
diff options
context:
space:
mode:
Diffstat (limited to 'support/arara/source/src/main/kotlin/org/islandoftex/arara/utils')
-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
9 files changed, 1697 insertions, 0 deletions
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
+ }
+ }
+}