// 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 @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 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)*\\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 { val result = mutableListOf() 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, arguments: List ): Set { 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 ): List { 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() 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 { // 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 } }