diff options
Diffstat (limited to 'support/arara/source/src/main/kotlin/org')
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 + } + } +} |