diff options
Diffstat (limited to 'support/arara/source/src/main/kotlin/org/islandoftex/arara/utils')
9 files changed, 1697 insertions, 0 deletions
diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/ClassLoadingUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/ClassLoadingUtils.kt new file mode 100644 index 0000000000..05a1031a8a --- /dev/null +++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/ClassLoadingUtils.kt @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: BSD-3-Clause +package org.islandoftex.arara.utils + +import java.io.File +import java.lang.reflect.InvocationTargetException +import java.net.MalformedURLException +import java.net.URLClassLoader + +/** + * Implements utilitary methods for classloading and object instantiation. + * + * @author Island of TeX + * @version 5.0 + * @since 4.0 + */ +object ClassLoadingUtils { + /** + * Indicator of success or failure of class loading. + */ + enum class ClassLoadingStatus { + SUCCESS, + FILE_NOT_FOUND, + MALFORMED_URL, + CLASS_NOT_FOUND, + ILLEGAL_ACCESS, + INSTANTIATION_EXCEPTION + } + + /** + * Loads a class from the provided file, potentially a Java archive. + * @param file File containing the Java bytecode (namely, a JAR). + * @param name The canonical name of the class. + * @return A pair representing the status and the class. + */ + fun loadClass(file: File, name: String): + Pair<ClassLoadingStatus, Class<*>> { + // status and class to be returned, + // it defaults to an object class + var value: Class<*> = Any::class.java + + // if file does not exist, nothing + // can be done, status is changed + val status = if (!file.exists()) { + ClassLoadingStatus.FILE_NOT_FOUND + } else { + // classloading involves defining + // a classloader and fetching the + // desired class from it, based on + // the provided file archive + try { + // creates a new classloader with + // the provided file (potentially + // a JAR file) + val classloader = URLClassLoader(arrayOf(file.toURI().toURL()), + ClassLoadingUtils::class.java.classLoader) + + // fetches the class from the + // instantiated classloader + value = Class.forName(name, true, classloader) + ClassLoadingStatus.SUCCESS + } catch (_: MalformedURLException) { + ClassLoadingStatus.MALFORMED_URL + } catch (_: ClassNotFoundException) { + ClassLoadingStatus.CLASS_NOT_FOUND + } + } + + // return a new pair based on the + // current status and class holder + return status to value + } + + /** + * Loads a class from the provided file, instantiating it. + * @param file File containing the Java bytecode (namely, a JAR). + * @param name The canonical name of the class. + * @return A pair representing the status and the class object. + */ + fun loadObject(file: File, name: String): Pair<ClassLoadingStatus, Any> { + // load the corresponding class + // based on the qualified name + val pair = loadClass(file, name) + + // status and object to be returned, + // it defaults to an object + var status = pair.first + var value = Any() + + // checks if the class actually + // exists, otherwise simply + // ignore instantiation + if (status == ClassLoadingStatus.SUCCESS) { + // object instantiation relies + // on the default constructor + // (without arguments), class + // must implement it + + // OBS: constructors with arguments + // must be invoked through reflection + try { + // get the class reference from + // the pair and instantiate it + // by invoking the default + // constructor (without arguments) + value = pair.second.getDeclaredConstructor().newInstance() + } catch (_: IllegalAccessException) { + status = ClassLoadingStatus.ILLEGAL_ACCESS + } catch (_: InstantiationException) { + // the user wanted to instantiate an abstract class + status = ClassLoadingStatus.INSTANTIATION_EXCEPTION + } catch (_: InvocationTargetException) { + // the underlying constructor caused an exception + status = ClassLoadingStatus.INSTANTIATION_EXCEPTION + } + } + + // return a new pair based on the + // current status and object holder + return status to value + } +} diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/CommonUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/CommonUtils.kt new file mode 100644 index 0000000000..6887cd4e84 --- /dev/null +++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/CommonUtils.kt @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: BSD-3-Clause +package org.islandoftex.arara.utils + +import java.io.File +import java.io.IOException +import java.util.MissingFormatArgumentException +import java.util.regex.Pattern +import kotlin.math.ln +import kotlin.math.pow +import org.islandoftex.arara.Arara +import org.islandoftex.arara.configuration.AraraSpec +import org.islandoftex.arara.filehandling.FileHandlingUtils +import org.islandoftex.arara.localization.LanguageController +import org.islandoftex.arara.localization.Messages +import org.islandoftex.arara.model.AraraException +import org.islandoftex.arara.ruleset.Argument + +/** + * Implements common utilitary methods. + * + * @author Island of TeX + * @version 5.0 + * @since 4.0 + */ +object CommonUtils { + // the application messages obtained from the + // language controller + private val messages = LanguageController + + /** + * Gets the list of file types as string, in order. + * + * @return A string representation of the list of file types, in order. + */ + val fileTypesList: String + get() = "[ " + Arara.config[AraraSpec.Execution.fileTypes] + .joinToString(" | ") + " ]" + + /** + * Gets the rule error header, containing the identifier and the path, if + * any. + * + * @return A string representation of the rule error header, containing the + * identifier and the path, if any. + */ + val ruleErrorHeader: String + get() { + return if (Arara.config[AraraSpec.Execution.InfoSpec.ruleId] != null && + Arara.config[AraraSpec.Execution.InfoSpec.rulePath] != null) { + val id = Arara.config[AraraSpec.Execution.InfoSpec.ruleId]!! + val path = Arara.config[AraraSpec.Execution.InfoSpec.rulePath]!! + messages.getMessage( + Messages.ERROR_RULE_IDENTIFIER_AND_PATH, + id, + path + ) + " " + } else { + "" + } + } + + /** + * Gets a list of all rule paths. + * + * @return A list of all rule paths. + * @throws AraraException Something wrong happened, to be caught in the + * higher levels. + */ + val allRulePaths: List<String> + @Throws(AraraException::class) + get() = Arara.config[AraraSpec.Execution.rulePaths].map { + val location = File(InterpreterUtils.construct(it, "quack")) + FileHandlingUtils.getParentCanonicalPath(location) + } + + /** + * Returns the exit status of the application. + * + * @return An integer representing the exit status of the application. + */ + val exitStatus: Int + get() = Arara.config[AraraSpec.Execution.status] + + /** + * Gets the preamble content, converting a single string into a list of + * strings, based on new lines. + * + * @return A list of strings representing the preamble content. + */ + val preambleContent: List<String> + get() = if (Arara.config[AraraSpec.Execution.preamblesActive]) { + Arara.config[AraraSpec.Execution.preamblesContent] + .split("\n") + .dropLastWhile { it.isEmpty() } + .toList() + } else { + listOf() + } + + /** + * Checks if the input string is equal to a valid boolean value. + * + * @param value The input string. + * @return A boolean value represented by the provided string. + * @throws AraraException Something wrong happened, to be caught in the + * higher levels. + */ + @Throws(AraraException::class) + fun checkBoolean(value: String): Boolean { + val yes = listOf("yes", "true", "1", "on") + val no = listOf("no", "false", "0", "off") + return if (!yes.union(no).contains(value.toLowerCase())) { + throw AraraException( + messages.getMessage( + Messages.ERROR_CHECKBOOLEAN_NOT_VALID_BOOLEAN, + value + ) + ) + } else { + yes.contains(value.toLowerCase()) + } + } + + /** + * Removes the keyword from the beginning of the provided string. + * + * @param line A string to be analyzed. + * @return The provided string without the keyword. + */ + fun removeKeyword(line: String?): String? { + return if (line == null) null + else removeKeywordNotNull(line) + } + + /** + * Removes the keyword from the beginning of the provided string. + * + * @param line A string to be analyzed. + * @return The provided string without the keyword. + */ + fun removeKeywordNotNull(line: String): String { + var tempLine = line + val pattern = "^(\\s)*<arara>\\s".toPattern() + val matcher = pattern.matcher(tempLine) + if (matcher.find()) { + tempLine = tempLine.substring(matcher.end()) + } + return tempLine.trim() + } + + /** + * Flattens a potential list of lists into a list of objects. + * + * @param list The list to be flattened. + * @return The flattened list. + */ + // TODO: check nullity + fun flatten(list: List<*>): List<Any> { + val result = mutableListOf<Any>() + list.forEach { item -> + if (item is List<*>) + result.addAll(flatten(item)) + else + result.add(item as Any) + } + return result + } + + /** + * Gets a set of strings containing unknown keys from a map and a list. It + * is a set difference from the keys in the map and the entries in the list. + * + * @param parameters The map of parameters. + * @param arguments The list of arguments. + * @return A set of strings representing unknown keys from a map and a list. + */ + fun getUnknownKeys( + parameters: Map<String, Any>, + arguments: List<Argument> + ): Set<String> { + val found = parameters.keys + val expected = arguments.mapNotNull { it.identifier } + return found.subtract(expected) + } + + /** + * Gets a human readable representation of a size. + * + * @param size The byte size to be converted. + * @return A string representation of the size. + */ + @Suppress("MagicNumber") + fun byteSizeToString(size: Long): String { + val language = Arara.config[AraraSpec.Execution.language] + val conversionFactor = 1000.0 + return if (size < conversionFactor) "$size B" + else + (ln(size.toDouble()) / ln(conversionFactor)).toInt().let { exp -> + "%.1f %sB".format(language.locale, + size / conversionFactor.pow(exp.toDouble()), + "kMGTPE"[exp - 1]) + } + } + + /** + * Generates a string based on a list of objects, separating each one of + * them by one space. + * + * @param objects A list of objects. + * @return A string based on the list of objects, separating each one of + * them by one space. Empty values are not considered. + */ + fun generateString(vararg objects: Any): String = objects + .map { it.toString() }.filter { it.isNotEmpty() } + .joinToString(" ") + + /** + * Checks if the file based on the provided extension contains the provided + * regex. + * + * @param extension The file extension. + * @param regex The regex. + * @return A boolean value indicating if the file contains the provided + * regex. + * @throws AraraException Something wrong happened, to be caught in the + * higher levels. + */ + @Throws(AraraException::class) + fun checkRegex(extension: String, regex: String): Boolean { + val file = File(FileHandlingUtils.getPath(extension)) + return checkRegex(file, regex) + } + + /** + * Checks if the file contains the provided regex. + * + * As we use [File.readText] this should not be called on files > 2GB. + * + * @param file The file. + * @param regex The regex. + * @return A boolean value indicating if the file contains the provided + * regex. + * @throws AraraException Something wrong happened, to be caught in the + * higher levels. + */ + @Throws(AraraException::class) + fun checkRegex(file: File, regex: String): Boolean { + try { + val text = file.readText() + val pattern = Pattern.compile(regex) + val matcher = pattern.matcher(text) + return matcher.find() + } catch (exception: IOException) { + throw AraraException( + messages.getMessage( + Messages.ERROR_CHECKREGEX_IO_EXCEPTION, + file.name + ), + exception + ) + } + } + + /** + * Replicates a string pattern based on a list of objects, generating a list + * as result. + * + * @param pattern The string pattern. + * @param values The list of objects to be merged with the pattern. + * @return A list containing the string pattern replicated to each object + * from the list. + * @throws AraraException Something wrong happened, to be caught in the + * higher levels. + */ + @Throws(AraraException::class) + fun replicateList( + pattern: String, + values: List<Any> + ): List<Any> { + return try { + values.map { String.format(pattern, it) } + } catch (exception: MissingFormatArgumentException) { + throw AraraException( + messages.getMessage( + Messages.ERROR_REPLICATELIST_MISSING_FORMAT_ARGUMENTS_EXCEPTION + ), + exception + ) + } + } + + /** + * Checks if the provided operating system string holds according to the + * underlying operating system. + * + * Supported operating systems: + * + * * Windows + * * Linux + * * Mac OS X + * * Unix (Linux || Mac OS) + * * Cygwin + * + * @param value A string representing an operating system. + * @return A boolean value indicating if the provided string refers to the + * underlying operating system. + * @throws AraraException Something wrong happened, to be caught in the + * higher levels. + */ + @Throws(AraraException::class) + fun checkOS(value: String): Boolean { + fun checkOSProperty(key: String): Boolean = + getSystemPropertyOrNull("os.name") + ?.toLowerCase()?.startsWith(key.toLowerCase()) ?: false + + val values = mutableMapOf<String, Boolean>() + values["windows"] = checkOSProperty("Windows") + values["linux"] = checkOSProperty("Linux") + values["mac"] = checkOSProperty("Mac OS X") + values["unix"] = checkOSProperty("Mac OS X") || + checkOSProperty("Linux") + values["cygwin"] = SystemCallUtils["cygwin"] as Boolean + if (!values.containsKey(value.toLowerCase())) { + throw AraraException( + messages.getMessage( + Messages.ERROR_CHECKOS_INVALID_OPERATING_SYSTEM, + value + ) + ) + } + // will never throw, see check above + return values.getValue(value.toLowerCase()) + } + + /** + * Gets the system property according to the provided key, or resort to the + * fallback value if an exception is thrown or if the key is invalid. + * + * @param key The system property key. + * @param fallback The fallback value. + * @return A string containing the system property value or the fallback. + */ + fun getSystemProperty(key: String, fallback: String): String = + System.getProperties().runCatching { + getOrDefault(key, fallback).toString().takeIf { it != "" } + }.getOrNull() ?: fallback + + /** + * Access a system property. + * + * @param key The key of the property. + * @return The value of the system property or null if there is an + * exception. + */ + fun getSystemPropertyOrNull(key: String): String? = + System.getProperties().runCatching { getValue(key).toString() } + .getOrNull() + + /** + * Generates a list of filenames from the provided command based on a list + * of extensions for each underlying operating system. + * + * @param command A string representing the command. + * @return A list of filenames. + */ + private fun appendExtensions(command: String): List<String> { + // list of extensions, specific for + // each operating system (in fact, it + // is more Windows specific) + val extensions = if (checkOS("windows")) { + // the application is running on + // Windows, so let's look for the + // following extensions in order + + // this list is actually a sublist from + // the original Windows PATHEXT environment + // variable which holds the list of executable + // extensions that Windows supports + listOf(".com", ".exe", ".bat", ".cmd") + } else { + // no Windows, so the default + // extension will be just an + // empty string + listOf("") + } + + // return the resulting list holding the + // filenames generated from the + // provided command + return extensions.map { "$command$it" } + } + + /** + * Checks if the provided command name is reachable from the system path. + * + * @param command A string representing the command. + * @return A logic value. + */ + fun isOnPath(command: String): Boolean { + // first and foremost, let's build the list + // of filenames based on the underlying + // operating system + val filenames = appendExtensions(command) + return kotlin.runCatching { + // break the path into several parts + // based on the path separator symbol + System.getenv("PATH").split(File.pathSeparator) + .asSequence() + .mapNotNull { File(it).listFiles() } + // if the search does not return an empty + // list, one of the filenames got a match, + // and the command is available somewhere + // in the system path + .firstOrNull { + it.any { file -> + filenames.contains(file.name) && !file.isDirectory + } + }?.let { true } + }.getOrNull() ?: false + // otherwise (and in case of an exception) it is not in the path + } +} diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/DisplayUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/DisplayUtils.kt new file mode 100644 index 0000000000..213a9a21c3 --- /dev/null +++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/DisplayUtils.kt @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: BSD-3-Clause +package org.islandoftex.arara.utils + +import org.islandoftex.arara.Arara +import org.islandoftex.arara.configuration.AraraSpec +import org.islandoftex.arara.configuration.ConfigurationUtils +import org.islandoftex.arara.filehandling.FileHandlingUtils +import org.islandoftex.arara.localization.LanguageController +import org.islandoftex.arara.localization.Messages +import org.islandoftex.arara.model.AraraException +import org.islandoftex.arara.ruleset.Conditional +import org.slf4j.LoggerFactory + +/** + * Implements display utilitary methods. + * + * @author Island of TeX + * @version 5.0 + * @since 4.0 + */ +object DisplayUtils { + // the application messages obtained from the + // language controller + private val messages = LanguageController + + // get the logger context from a factory + private val logger = LoggerFactory.getLogger(DisplayUtils::class.java) + + /** + * The length of the longest result match as integer. + */ + private val longestMatch: Int = listOf( + messages.getMessage(Messages.INFO_LABEL_ON_SUCCESS), + messages.getMessage(Messages.INFO_LABEL_ON_FAILURE), + messages.getMessage(Messages.INFO_LABEL_ON_ERROR)) + .map { it.length }.max()!! + /** + * If the longest match is longer than the width, then it will be truncated + * to this length. + */ + private const val shortenedLongestMatch = 10 + + /** + * The default terminal width defined in the settings. + */ + private val width: Int + get() = Arara.config[AraraSpec.Application.width] + + /** + * Checks if the execution is in dry-run mode. + */ + private val isDryRunMode: Boolean + get() = Arara.config[AraraSpec.Execution.dryrun] + + /** + * Checks if the execution is in verbose mode. + */ + private val isVerboseMode: Boolean + get() = Arara.config[AraraSpec.Execution.verbose] + + /** + * The application path. + */ + private val applicationPath: String + get() = try { + ConfigurationUtils.applicationPath.toString() + } catch (ae: AraraException) { + "[unknown application path]" + } + + /** + * Displays the short version of the current entry in the terminal. + * + * @param name Rule name. + * @param task Task name. + */ + private fun buildShortEntry(name: String, task: String) { + val result = if (longestMatch >= width) + shortenedLongestMatch + else + longestMatch + val space = width - result - 1 + val line = "($name) $task ".abbreviate(space - "... ".length) + print(line.padEnd(space, '.') + " ") + } + + /** + * Displays the short version of the current entry result in the terminal. + * + * @param value The boolean value to be displayed. + */ + private fun buildShortResult(value: Boolean) { + val result = longestMatch + println(getResult(value).padStart(result)) + } + + /** + * Displays the current entry result in the terminal. + * + * @param value The boolean value to be displayed. + */ + fun printEntryResult(value: Boolean) { + Arara.config[AraraSpec.UserInteraction.displayLine] = false + Arara.config[AraraSpec.UserInteraction.displayResult] = true + Arara.config[AraraSpec.Execution.status] = if (value) 0 else 1 + logger.info( + messages.getMessage( + Messages.LOG_INFO_TASK_RESULT + ) + " " + getResult(value) + ) + if (!isDryRunMode) { + if (!isVerboseMode) { + buildShortResult(value) + } else { + buildLongResult(value) + } + } + } + + /** + * Displays a long version of the current entry result in the terminal. + * + * @param value The boolean value to be displayed + */ + private fun buildLongResult(value: Boolean) { + val width = width + println("\n" + (" " + getResult(value)).padStart(width, '-')) + } + + /** + * Displays the current entry in the terminal. + * + * @param name The rule name. + * @param task The task name. + */ + fun printEntry(name: String, task: String) { + logger.info( + messages.getMessage( + Messages.LOG_INFO_INTERPRET_TASK, + task, + name + ) + ) + Arara.config[AraraSpec.UserInteraction.displayLine] = true + Arara.config[AraraSpec.UserInteraction.displayResult] = false + if (!isDryRunMode) { + if (!isVerboseMode) { + buildShortEntry(name, task) + } else { + buildLongEntry(name, task) + } + } else { + buildDryRunEntry(name, task) + } + } + + /** + * Displays a long version of the current entry in the terminal. + * + * @param name Rule name. + * @param task Task name. + */ + private fun buildLongEntry(name: String, task: String) { + if (Arara.config[AraraSpec.UserInteraction.displayRolling]) { + addNewLine() + } else { + Arara.config[AraraSpec.UserInteraction.displayRolling] = true + } + println(displaySeparator()) + println("($name) $task".abbreviate(width)) + println(displaySeparator()) + } + + /** + * Displays a dry-run version of the current entry in the terminal. + * + * @param name The rule name. + * @param task The task name. + */ + private fun buildDryRunEntry(name: String, task: String) { + if (Arara.config[AraraSpec.UserInteraction.displayRolling]) { + addNewLine() + } else { + Arara.config[AraraSpec.UserInteraction.displayRolling] = true + } + println("[DR] ($name) $task".abbreviate(width)) + println(displaySeparator()) + } + + /** + * Displays the exception in the terminal. + * + * @param exception The exception object. + */ + fun printException(exception: AraraException) { + Arara.config[AraraSpec.UserInteraction.displayException] = true + Arara.config[AraraSpec.Execution.status] = 2 + + val display = Arara.config[AraraSpec.UserInteraction.displayLine] + if (Arara.config[AraraSpec.UserInteraction.displayResult]) + addNewLine() + if (display) { + if (!isDryRunMode) { + if (!isVerboseMode) { + buildShortError() + } else { + buildLongError() + } + addNewLine() + } + } + val text = (if (exception.hasException()) + exception.message + " " + messages.getMessage( + Messages.INFO_DISPLAY_EXCEPTION_MORE_DETAILS) + else + exception.message) ?: "EXCEPTION PROVIDES NO MESSAGE" + // TODO: check null handling + logger.error(text) + wrapText(text) + if (exception.hasException()) { + addNewLine() + displayDetailsLine() + val details = exception.exception!!.message!! + logger.error(details) + wrapText(details) + } + } + + /** + * Gets the string representation of the provided boolean value. + * + * @param value The boolean value. + * @return The string representation. + */ + private fun getResult(value: Boolean): String { + return if (value) + messages.getMessage( + Messages.INFO_LABEL_ON_SUCCESS + ) + else + messages.getMessage(Messages.INFO_LABEL_ON_FAILURE) + } + + /** + * Displays the short version of an error in the terminal. + */ + private fun buildShortError() { + val result = longestMatch + println(messages.getMessage(Messages.INFO_LABEL_ON_ERROR) + .padStart(result)) + } + + /** + * Displays the long version of an error in the terminal. + */ + private fun buildLongError() { + println((" " + messages.getMessage(Messages.INFO_LABEL_ON_ERROR)) + .padStart(width, '-')) + } + + /** + * Displays the provided text wrapped nicely according to the default + * terminal width. + * + * @param text The text to be displayed. + */ + fun wrapText(text: String) = println(text.wrap(width)) + + /** + * Displays the rule authors in the terminal. + * + * @param authors The list of authors. + */ + fun printAuthors(authors: List<String>) { + val line = if (authors.size == 1) + messages.getMessage(Messages.INFO_LABEL_AUTHOR) + else + messages.getMessage(Messages.INFO_LABEL_AUTHORS) + val text = if (authors.isEmpty()) + messages.getMessage(Messages.INFO_LABEL_NO_AUTHORS) + else + authors.joinToString(", ") { it.trim() } + wrapText("$line $text") + } + + /** + * Displays the current conditional in the terminal. + * + * @param conditional The conditional object. + */ + fun printConditional(conditional: Conditional) { + if (conditional.type !== Conditional.ConditionalType.NONE) { + wrapText(messages.getMessage(Messages.INFO_LABEL_CONDITIONAL) + + " (" + conditional.type + ") " + + conditional.condition) + } + } + + /** + * Displays the file information in the terminal. + */ + fun printFileInformation() { + val file = Arara.config[AraraSpec.Execution.reference] + val version = Arara.config[AraraSpec.Application.version] + val line = messages.getMessage( + Messages.INFO_DISPLAY_FILE_INFORMATION, + file.name, + CommonUtils.byteSizeToString(file.length()), + FileHandlingUtils.getLastModifiedInformation(file) + ) + logger.info(messages.getMessage( + Messages.LOG_INFO_WELCOME_MESSAGE, + version + )) + logger.info(displaySeparator()) + logger.debug("::: arara @ $applicationPath") + logger.debug("::: Java %s, %s".format( + CommonUtils.getSystemProperty("java.version", + "[unknown version]"), + CommonUtils.getSystemProperty("java.vendor", + "[unknown vendor]") + )) + logger.debug("::: %s".format( + CommonUtils.getSystemProperty("java.home", + "[unknown location]") + )) + logger.debug("::: %s, %s, %s".format( + CommonUtils.getSystemProperty("os.name", + "[unknown OS name]"), + CommonUtils.getSystemProperty("os.arch", + "[unknown OS arch]"), + CommonUtils.getSystemProperty("os.version", + "[unknown OS version]") + )) + logger.debug("::: user.home @ %s".format( + CommonUtils.getSystemProperty("user.home", + "[unknown user's home directory]") + )) + logger.debug("::: CF @ %s".format(Arara.config[AraraSpec.Execution + .configurationName])) + logger.debug(displaySeparator()) + logger.info(line) + wrapText(line) + addNewLine() + } + + /** + * Displays the elapsed time in the terminal. + * + * @param seconds The elapsed seconds. + */ + fun printTime(seconds: Double) { + val language = Arara.config[AraraSpec.Execution.language] + + if (Arara.config[AraraSpec.UserInteraction.displayTime]) { + if (Arara.config[AraraSpec.UserInteraction.displayLine] || + Arara.config[AraraSpec.UserInteraction.displayException]) + addNewLine() + + val text = messages.getMessage( + Messages.INFO_DISPLAY_EXECUTION_TIME, + "%1.2f".format(language.locale, seconds)) + logger.info(text) + wrapText(text) + } + } + + /** + * Displays the application logo in the terminal. + */ + fun printLogo() { + println(""" + __ _ _ __ __ _ _ __ __ _ + / _` | '__/ _` | '__/ _` | + | (_| | | | (_| | | | (_| | + \__,_|_| \__,_|_| \__,_| + """.trimIndent()) + addNewLine() + } + + /** + * Adds a new line in the terminal. + */ + private fun addNewLine() { + println() + } + + /** + * Displays a line containing details. + */ + private fun displayDetailsLine() { + val line = messages.getMessage( + Messages.INFO_LABEL_ON_DETAILS) + " " + println(line.abbreviate(width).padEnd(width, '-')) + } + + /** + * Gets the output separator with the provided text. + * + * @param message The provided text. + * @return A string containing the output separator with the provided text. + */ + fun displayOutputSeparator(message: String): String { + return " $message ".center(width, '-') + } + + /** + * Gets the line separator. + * + * @return A string containing the line separator. + */ + fun displaySeparator(): String { + return "-".repeat(width) + } +} diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/Extensions.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/Extensions.kt new file mode 100644 index 0000000000..10ace0b7f1 --- /dev/null +++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/Extensions.kt @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: BSD-3-Clause +package org.islandoftex.arara.utils + +import kotlin.math.ceil + +/** + * Abbreviate a String to a maximal width. + * + * @param maxWidth The maximal width to truncate to. + * @param ellipsis The string to use to indicate an ellipsis. + * @throws IllegalArgumentException If the string would consist only of the + * ellipsis after shortening. + * @return The abbreviated string. + */ +@Throws(IllegalArgumentException::class) +fun String.abbreviate(maxWidth: Int, ellipsis: String = "…"): String { + return when { + maxWidth < ellipsis.length + 1 -> + throw IllegalArgumentException("Can't abbreviate text further") + this.length < maxWidth -> this + else -> this.substring(0, maxWidth - ellipsis.length) + ellipsis + } +} + +/** + * Center a string within a specified number of columns. + * + * This does not center anything if the string is longer than the specified + * width. + * + * @param width The number of columns. + * @param padChar The char to pad with. + * @return The padded string. + */ +fun String.center(width: Int, padChar: Char): String { + return if (this.length > width) this + else { + val charsLeft = width - this.length + padChar.toString().repeat(charsLeft / 2) + this + + padChar.toString().repeat(ceil(charsLeft.toDouble() / 2.0).toInt()) + } +} + +/** + * Wrap text at a specified width. + * + * Algorithm from Wikipedia: + * https://en.wikipedia.org/wiki/Line_wrap_and_word_wrap#Minimum_number_of_lines + * + * @param width The width to wrap at. + * @return Wrapped text. + */ +fun String.wrap(width: Int): String { + val words = this.split(" ") + var wrapped = words[0] + var spaceLeft = width - wrapped.length + words.drop(1).forEach { + val len = it.length + wrapped += if (len + 1 > spaceLeft) { + spaceLeft = width - len + "\n$it" + } else { + spaceLeft -= len + 1 + " $it" + } + } + return wrapped +} diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/InterpreterUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/InterpreterUtils.kt new file mode 100644 index 0000000000..00313a7714 --- /dev/null +++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/InterpreterUtils.kt @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: BSD-3-Clause +package org.islandoftex.arara.utils + +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.OutputStream +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import org.islandoftex.arara.Arara +import org.islandoftex.arara.configuration.AraraSpec +import org.islandoftex.arara.localization.LanguageController +import org.islandoftex.arara.localization.Messages +import org.islandoftex.arara.model.AraraException +import org.islandoftex.arara.ruleset.Command +import org.islandoftex.arara.ruleset.Conditional +import org.slf4j.LoggerFactory +import org.zeroturnaround.exec.InvalidExitValueException +import org.zeroturnaround.exec.ProcessExecutor +import org.zeroturnaround.exec.listener.ShutdownHookProcessDestroyer + +/** + * Implements interpreter utilitary methods. + * + * @author Island of TeX + * @version 5.0 + * @since 4.0 + */ +object InterpreterUtils { + // the application messages obtained from the + // language controller + private val messages = LanguageController + + // get the logger context from a factory + private val logger = LoggerFactory.getLogger(InterpreterUtils::class.java) + + /** + * Checks if the current conditional has a prior evaluation. + * + * @param conditional The current conditional object. + * @return A boolean value indicating if the current conditional has a prior + * evaluation. + */ + fun runPriorEvaluation(conditional: Conditional): Boolean { + return if (Arara.config[AraraSpec.Execution.dryrun]) { + false + } else { + when (conditional.type) { + Conditional.ConditionalType.IF, + Conditional.ConditionalType.WHILE, + Conditional.ConditionalType.UNLESS -> true + else -> false + } + } + } + + @ExperimentalTime + private fun getProcessExecutorForCommand( + command: Command, + buffer: OutputStream + ): + ProcessExecutor { + val timeOutValue = Arara.config[AraraSpec.Execution.timeoutValue] + var executor = ProcessExecutor().command((command).elements) + .directory(command.workingDirectory.absoluteFile) + .addDestroyer(ShutdownHookProcessDestroyer()) + if (Arara.config[AraraSpec.Execution.timeout]) { + if (timeOutValue == Duration.ZERO) { + throw AraraException(messages.getMessage(Messages + .ERROR_RUN_TIMEOUT_INVALID_RANGE)) + } + executor = executor.timeout(timeOutValue.toLongNanoseconds(), + TimeUnit.NANOSECONDS) + } + val tee = if (Arara.config[AraraSpec.Execution.verbose]) { + executor = executor.redirectInput(System.`in`) + TeeOutputStream(System.out, buffer) + } else { + TeeOutputStream(buffer) + } + executor = executor.redirectOutput(tee).redirectError(tee) + return executor + } + + /** + * Runs the command in the underlying operating system. + * + * @param command An object representing the command. + * @return An integer value representing the exit code. + * @throws AraraException Something wrong happened, to be caught in the + * higher levels. + */ + @ExperimentalTime + @Throws(AraraException::class) + fun run(command: Command): Int { + val buffer = ByteArrayOutputStream() + val executor = getProcessExecutorForCommand(command, buffer) + return executor.runCatching { + val exit = execute().exitValue + logger.info(DisplayUtils.displayOutputSeparator( + messages.getMessage(Messages.LOG_INFO_BEGIN_BUFFER))) + logger.info(buffer.toString()) + logger.info(DisplayUtils.displayOutputSeparator( + messages.getMessage(Messages.LOG_INFO_END_BUFFER))) + exit + }.getOrElse { + throw AraraException(messages.getMessage( + when (it) { + is IOException -> Messages.ERROR_RUN_IO_EXCEPTION + is InterruptedException -> + Messages.ERROR_RUN_INTERRUPTED_EXCEPTION + is InvalidExitValueException -> + Messages.ERROR_RUN_INVALID_EXIT_VALUE_EXCEPTION + is TimeoutException -> + Messages.ERROR_RUN_TIMEOUT_EXCEPTION + else -> Messages.ERROR_RUN_GENERIC_EXCEPTION + }), it) + } + } + + /** + * Builds the rule path based on the rule name and returns the corresponding + * file location. + * + * @param name The rule name. + * @return The rule file. + * @throws AraraException Something wrong happened, to be caught in the + * higher levels. + */ + @Throws(AraraException::class) + fun buildRulePath(name: String): File? { + Arara.config[AraraSpec.Execution.rulePaths].forEach { path -> + val location = File(construct(path, name)) + if (location.exists()) + return location + } + return null + } + + /** + * Constructs the path given the current path and the rule name. + * + * @param path The current path. + * @param name The rule name. + * @return The constructed path. + * @throws AraraException Something wrong happened, to be caught in the + * higher levels. + */ + @Throws(AraraException::class) + fun construct(path: String, name: String): String { + val fileName = "$name.yaml" + val location = File(path) + return if (location.isAbsolute) { + location.resolve(fileName).toString() + } else { + Arara.config[AraraSpec.Execution.workingDirectory] + // first resolve the path (rule path) against the working + // directory, then the rule name we want to resolve + .resolve(path).resolve(fileName).toAbsolutePath().toString() + } + } +} diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/LoggingUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/LoggingUtils.kt new file mode 100644 index 0000000000..f9719fa31f --- /dev/null +++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/LoggingUtils.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: BSD-3-Clause +package org.islandoftex.arara.utils + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.joran.JoranConfigurator +import ch.qos.logback.core.joran.spi.JoranException +import java.io.InputStream +import org.islandoftex.arara.Arara +import org.islandoftex.arara.configuration.AraraSpec +import org.slf4j.LoggerFactory + +/** + * Implements the logging controller. This class actually sets the logging + * configuration in order to allow appending results to a file. + * + * @author Island of TeX + * @version 5.0 + * @since 4.0 + */ +object LoggingUtils { + // configuration resource as an input stream + // the configuration is actually a XML file. + private val resource: InputStream by lazy { + LoggingUtils::class.java + .getResourceAsStream("/org/islandoftex/arara/configuration/logback.xml") + } + + /** + * Sets the logging configuration according to the provided boolean value. + * If the value is set to true, the log entries will be appended to a file, + * otherwise the logging feature will keep silent. + * @param enable A boolean value that indicates the logging behaviour + * throughout the application. + */ + fun enableLogging(enable: Boolean) { + // get the logger context from a factory, set a + // new context and reset it + val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext + + try { + // get a new configuration and set + // the context + val configurator = JoranConfigurator() + configurator.context = loggerContext + loggerContext.reset() + + // if enabled, the log entries will be + // appended to a file, otherwise it will + // remain silent + if (enable) { + // set the file name and configure + // the logging controller to append + // entries to the file + val name = Arara.config[AraraSpec.Execution.logName] + loggerContext.putProperty("name", name) + configurator.doConfigure(resource) + } + } catch (_: JoranException) { + // quack, quack, quack! + } + } + + /** + * Initializes the logging controller by disabling it. I don't want an odd + * behaviour out of the box. + */ + fun init() { + enableLogging(false) + } +} diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/MessageUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/MessageUtils.kt new file mode 100644 index 0000000000..ae695c44fb --- /dev/null +++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/MessageUtils.kt @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: BSD-3-Clause +package org.islandoftex.arara.utils + +import javax.swing.JOptionPane +import javax.swing.UIManager +import org.islandoftex.arara.Arara +import org.islandoftex.arara.configuration.AraraSpec + +/** + * Implements utilitary methods for displaying messages. + * + * @author Island of TeX + * @version 5.0 + * @since 4.0 + */ +object MessageUtils { + // holds the default width for the + // message body, in pixels + private const val WIDTH = 250 + + // let's start the UI manager and set + // the default look and feel to be as + // close as possible to the system + init { + // get the current look and feel + var laf = Arara.config[AraraSpec.UserInteraction.lookAndFeel] + + // check if one is actually set + if (laf != "none") { + // use a special keyword to indicate + // the use of a system look and feel + if (laf == "system") { + laf = UIManager.getSystemLookAndFeelClassName() + } + + // let's try it, in case it fails, + // rely to the default look and feel + try { + // get the system look and feel name + // and try to set it as default + UIManager.setLookAndFeel(laf) + } catch (_: Exception) { + // quack, quack, quack + } + } + } + + /** + * Normalizes the icon type to one of the five available icons. + * @param value An integer value. + * @return The normalized integer value. + */ + @Suppress("MagicNumber") + private fun normalizeIconType(value: Int): Int { + // do the normalization according to the available + // icons in the underlying message implementation + return when (value) { + 1 -> JOptionPane.ERROR_MESSAGE + 2 -> JOptionPane.INFORMATION_MESSAGE + 3 -> JOptionPane.WARNING_MESSAGE + 4 -> JOptionPane.QUESTION_MESSAGE + else -> JOptionPane.PLAIN_MESSAGE + } + } + + /** + * Normalizes the message width, so only valid nonzero values are accepted. + * @param value An integer value corresponding to the message width. + * @return The normalized width. + */ + private fun normalizeMessageWidth(value: Int): Int { + return if (value > 0) value else WIDTH + } + + /** + * Shows the message. + * @param width Integer value, in pixels. + * @param type Type of message. + * @param title Title of the message. + * @param text Text of the message. + */ + fun showMessage( + width: Int, + type: Int, + title: String, + text: String + ) { + // effectively shows the message based + // on the provided parameters + JOptionPane.showMessageDialog(null, + String.format( + "<html><body style=\"width:%dpx\">%s</body></html>", + normalizeMessageWidth(width), + text), + title, + normalizeIconType(type) + ) + } + + /** + * Shows the message. It relies on the default width. + * @param type Type of message. + * @param title Title of the message. + * @param text Text of the message. + */ + fun showMessage(type: Int, title: String, text: String) { + showMessage(WIDTH, type, title, text) + } + + /** + * Shows a message with options presented as an array of buttons. + * @param width Integer value, in pixels. + * @param type Type of message. + * @param title Title of the message. + * @param text Text of the message. + * @param buttons An array of objects to be presented as buttons. + * @return The index of the selected button, starting from 1. + */ + fun showOptions( + width: Int, + type: Int, + title: String, + text: String, + vararg buttons: Any + ): Int { + // returns the index of the selected button, + // zero if nothing is selected + return JOptionPane.showOptionDialog(null, + String.format( + "<html><body style=\"width:%dpx\">%s</body></html>", + normalizeMessageWidth(width), + text), + title, + JOptionPane.DEFAULT_OPTION, + normalizeIconType(type), null, + buttons, + buttons[0] + ) + 1 + } + + /** + * Shows a message with options presented as an array of buttons. It relies + * on the default width. + * @param type Type of message. + * @param title Title of the message. + * @param text Text of the message. + * @param buttons An array of objects to be presented as buttons. + * @return The index of the selected button, starting from 1. + */ + @Suppress("SpreadOperator") + fun showOptions( + type: Int, + title: String, + text: String, + vararg buttons: Any + ): Int { + return showOptions(WIDTH, type, title, text, *buttons) + } + + /** + * Shows a message with a text input. + * @param width Integer value, in pixels. + * @param type Type of message. + * @param title Title of the message. + * @param text Text of the message. + * @return The string representing the input text. + */ + fun showInput( + width: Int, + type: Int, + title: String, + text: String + ): String { + // get the string from the + // input text, if any + val input = JOptionPane.showInputDialog(null, + String.format( + "<html><body style=\"width:%dpx\">%s</body></html>", + normalizeMessageWidth(width), + text), + title, + normalizeIconType(type)) + + // if the input is not null, that is, + // the user actually typed something + // return the trimmed string otherwise + // an empty string + return input?.trim() ?: "" + } + + /** + * Shows a message with a text input. It relies on the default width. + * @param type Type of message. + * @param title Title of the message. + * @param text Text of the message. + * @return The string representing the input text. + */ + fun showInput(type: Int, title: String, text: String): String { + return showInput(WIDTH, type, title, text) + } + + /** + * Shows a message with options presented as a dropdown list of elements. + * @param width Integer value, in pixels. + * @param type Type of message. + * @param title Title of the message. + * @param text Text of the message. + * @param elements An array of objects representing the elements. + * @return The index of the selected element, starting from 1. + */ + fun showDropdown( + width: Int, + type: Int, + title: String, + text: String, + vararg elements: Any + ): Int { + // show the dropdown list and get + // the selected object, if any + val index = JOptionPane.showInputDialog(null, + String.format( + "<html><body style=\"width:%dpx\">%s</body></html>", + normalizeMessageWidth(width), + text), + title, + normalizeIconType(type), null, + elements, + elements[0]) + + // if it's not a null object, let's + // find the corresponding index + if (index != null) { + elements.forEachIndexed { i, value -> + // if the element is found, simply + // return the index plus 1, as zero + // corresponds to no selection at all + if (value == index) { + return i + 1 + } + } + } + + // nothing was selected, + // simply return zero + return 0 + } + + /** + * Shows a message with options presented as a dropdown list of elements. It + * relies on the default width. + * @param type Type of message. + * @param title Title of the message. + * @param text Text of the message. + * @param elements An array of objects representing the elements. + * @return The index of the selected element, starting from 1. + */ + @Suppress("SpreadOperator") + fun showDropdown( + type: Int, + title: String, + text: String, + vararg elements: Any + ): Int { + return showDropdown(WIDTH, type, title, text, *elements) + } +} diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/SystemCallUtils.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/SystemCallUtils.kt new file mode 100644 index 0000000000..9b443736a1 --- /dev/null +++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/SystemCallUtils.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: BSD-3-Clause +package org.islandoftex.arara.utils + +import org.islandoftex.arara.model.AraraException +import org.islandoftex.arara.ruleset.Command +import org.zeroturnaround.exec.ProcessExecutor + +/** + * Implements a system call controller. + * + * This class wraps a map that holds the result of system specific variables + * not directly available at runtime and makes unsafe calling of system + * commands available to rules. + * + * @author Island of TeX + * @version 5.0 + * @since 4.0 + */ +object SystemCallUtils { + /** + * When executing a system call goes wrong, this status code is returned. + */ + const val errorExitStatus = -99 + /** + * When executing a system call goes wrong and the caller asked for output, + * this output will be returned. + */ + const val errorCommandOutput = "" + + // the system call map which holds the result of + // system specific variables not directly available + // at runtime; the idea here is to provide wrappers + // to the map getter, so it could be easily manipulated + // create the new map instance to be + // populated on demand + private val map: MutableMap<String, Any> = mutableMapOf() + + // the commands map will allow the system call map being + // populated only on demand, that is, if the key is not + // found, this map will provide the corresponding method + // and update the value + // create the new map of commands and + // add the corresponding system calls + private val commands: MutableMap<String, () -> Any> = mutableMapOf( + "cygwin" to { + // Implements the body of the command. In this particular + // instance, it checks if we are inside a Cygwin environment. + // Returns a boolean value indicating if we are inside a Cygwin + // environment. + + // execute a new system call to 'uname -s', read the output + // as an UTF-8 string, lowercase it and check if it starts + // with the 'cygwin' string; if so, we are inside Cygwin + executeSystemCommand(Command("uname", "-s")) + .second.toLowerCase().startsWith("cygwin") + }) + + /** + * Gets the object indexed by the provided key. This method actually holds + * the map method of the very same name. + * + * @param key The provided map key. + * @return The object indexed by the provided map key. + */ + @Throws(NoSuchElementException::class, AraraException::class) + operator fun get(key: String): Any { + // if key is not found, meaning that + // the value wasn't required before + if (!map.containsKey(key)) { + if (commands.containsKey(key)) + // perform the system call and + // populate the corresponding value + map[key] = commands[key]!!.invoke() + else + throw AraraException("The requested key could not be " + + "translated into a command to get the call value.") + } + + // simply return the corresponding + // value based on the provided key + return map.getValue(key) + } + + /** + * Executes a system command from the underlying operating system and + * returns a pair containing the exit status and the command output as a + * string. + * @param command The system command to be executed. + * @return A pair containing the exit status and the system command output + * as a string. + */ + fun executeSystemCommand(command: Command): Pair<Int, String> { + return ProcessExecutor(command.elements).runCatching { + directory(command.workingDirectory) + readOutput(true) + execute().run { + exitValue to outputUTF8() + } + }.getOrElse { + // quack, quack, do nothing, just + // return a default error code + errorExitStatus to errorCommandOutput + } + } +} diff --git a/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/TeeOutputStream.kt b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/TeeOutputStream.kt new file mode 100644 index 0000000000..dee15176f2 --- /dev/null +++ b/support/arara/source/src/main/kotlin/org/islandoftex/arara/utils/TeeOutputStream.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: BSD-3-Clause +package org.islandoftex.arara.utils + +import java.io.IOException +import java.io.OutputStream + +/** + * Implements a stream splitter. + * + * @author Island of TeX + * @version 5.0 + * @since 4.0 + */ +class TeeOutputStream( + /** + * The array of output streams holds every output stream that will be + * written to. + */ + vararg outputStreams: OutputStream +) : OutputStream() { + /** + * An array of streams in which an object of this class will split data. + */ + private val streams: List<OutputStream> = outputStreams.toList() + + /** + * Writes the provided integer to each stream. + * + * @param b The provided integer + * @throws IOException An IO exception. + */ + @Throws(IOException::class) + override fun write(b: Int) = streams.forEach { it.write(b) } + + /** + * Writes the provided byte array to each stream, with the provided offset + * and length. + * + * @param b The byte array. + * @param offset The offset. + * @param length The length. + * @throws IOException An IO exception. + */ + @Throws(IOException::class) + override fun write(b: ByteArray, offset: Int, length: Int) = + streams.forEach { it.write(b, offset, length) } + + /** + * Flushes every stream. + * + * @throws IOException An IO exception. + */ + @Throws(IOException::class) + override fun flush() = streams.forEach { it.flush() } + + /** + * Closes every stream silently. + */ + override fun close() = streams.forEach { + try { + it.close() + } catch (ignored: IOException) { + // do nothing on purpose + } + } +} |