All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.saveourtool.save.plugins.fix.FixPlugin.kt Maven / Gradle / Ivy

The newest version!
@file:Suppress("FILE_IS_TOO_LONG")

package com.saveourtool.save.plugins.fix

import com.saveourtool.save.core.config.ActualFixFormat
import com.saveourtool.save.core.config.TestConfig
import com.saveourtool.save.core.files.createFile
import com.saveourtool.save.core.files.createRelativePathToTheRoot
import com.saveourtool.save.core.files.myDeleteRecursively
import com.saveourtool.save.core.files.readLines
import com.saveourtool.save.core.logging.describe
import com.saveourtool.save.core.logging.logDebug
import com.saveourtool.save.core.logging.logWarn
import com.saveourtool.save.core.plugin.ExtraFlags
import com.saveourtool.save.core.plugin.ExtraFlagsExtractor
import com.saveourtool.save.core.plugin.GeneralConfig
import com.saveourtool.save.core.plugin.Plugin
import com.saveourtool.save.core.plugin.resolvePlaceholdersFrom
import com.saveourtool.save.core.result.CountWarnings
import com.saveourtool.save.core.result.DebugInfo
import com.saveourtool.save.core.result.Fail
import com.saveourtool.save.core.result.Pass
import com.saveourtool.save.core.result.TestResult
import com.saveourtool.save.core.utils.PathSerializer
import com.saveourtool.save.core.utils.ProcessExecutionException
import com.saveourtool.save.core.utils.ProcessTimeoutException
import com.saveourtool.save.core.utils.calculatePathToSarifFile
import com.saveourtool.save.core.utils.singleIsInstance

import com.saveourtool.sarifutils.adapter.SarifFixAdapter
import io.github.petertrr.diffutils.diff
import io.github.petertrr.diffutils.patch.ChangeDelta
import io.github.petertrr.diffutils.patch.Delta
import io.github.petertrr.diffutils.patch.DeltaType
import io.github.petertrr.diffutils.patch.Patch
import io.github.petertrr.diffutils.text.DiffRowGenerator
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath

import kotlin.random.Random
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.PolymorphicModuleBuilder
import kotlinx.serialization.modules.subclass

private typealias PathPair = Pair

/**
 * A plugin that runs an executable on a file and compares output with expected output.
 * @property testConfig
 */
@Suppress("TooManyFunctions")
class FixPlugin(
    testConfig: TestConfig,
    testFiles: List,
    fileSystem: FileSystem,
    useInternalRedirections: Boolean = true,
    redirectTo: Path? = null,
) : Plugin(
    testConfig,
    testFiles,
    fileSystem,
    useInternalRedirections,
    redirectTo,
) {
    private val diffGenerator = DiffRowGenerator(
        showInlineDiffs = true,
        mergeOriginalRevised = false,
        inlineDiffByWord = false,
        oldTag = { _, start -> if (start) "[" else "]" },
        newTag = { _, start -> if (start) "<" else ">" },
    )

    // fixme: consider refactoring under https://github.com/saveourtool/save-cli/issues/156
    // fixme: should not be common for a class instance during https://github.com/saveourtool/save-cli/issues/28
    private var tmpDirectory: Path? = null
    private lateinit var extraFlagsExtractor: ExtraFlagsExtractor

    @Suppress("TOO_LONG_FUNCTION", "LongMethod")
    override fun handleFiles(files: Sequence): Sequence {
        testConfig.validateAndSetDefaults()
        val fixPluginConfig: FixPluginConfig = testConfig.pluginConfigs.singleIsInstance()
        val generalConfig: GeneralConfig = testConfig.pluginConfigs.singleIsInstance()
        val batchSize = requireNotNull(generalConfig.batchSize) {
            "`batchSize` is not set"
        }.toInt()
        val batchSeparator = requireNotNull(generalConfig.batchSeparator) {
            "`batchSeparator` is not set"
        }
        extraFlagsExtractor = ExtraFlagsExtractor(generalConfig, fs)

        return files.map { it as FixTestFiles }
            .chunked(batchSize)
            .map { chunk ->
                val testsPaths = chunk.map { it.test }
                val extraFlags = buildExtraFlags(testsPaths, batchSize)

                val testToExpectedFilesMap = chunk.map { it.test to it.expected }
                val testCopyToExpectedFilesMap = testToExpectedFilesMap.map { (test, expected) ->
                    createCopyOfTestFile(test, generalConfig, fixPluginConfig) to expected
                }

                val execCmd = buildExecCmd(generalConfig, fixPluginConfig, testCopyToExpectedFilesMap, batchSeparator, extraFlags)
                val time = generalConfig.timeOutMillis!!.times(testToExpectedFilesMap.size)

                logDebug("Executing fix plugin in ${fixPluginConfig.actualFixFormat?.name} mode")

                val executionResult = try {
                    pb.exec(execCmd, testConfig.getRootConfig().directory.toString(), redirectTo, time)
                } catch (ex: ProcessTimeoutException) {
                    logWarn("The following tests took too long to run and were stopped: ${chunk.map { it.test }}, timeout for single test: ${ex.timeoutMillis}")
                    return@map failTestResult(chunk, ex, execCmd)
                } catch (ex: ProcessExecutionException) {
                    return@map failTestResult(chunk, ex, execCmd)
                }

                val adjustedTestCopyToExpectedFilesMap = if (fixPluginConfig.actualFixFormat == ActualFixFormat.IN_PLACE) {
                    // hold testCopyToExpectedFilesMap as is
                    testCopyToExpectedFilesMap
                } else {
                    // replace test files with modified copies, obtained from sarif lib
                    val fixedTestCopyToExpectedFilesMap = applyFixesFromSarif(
                        executionResult.stdout,
                        fixPluginConfig,
                        testsPaths,
                        testCopyToExpectedFilesMap
                    )
                    fixedTestCopyToExpectedFilesMap
                }

                val stdout = executionResult.stdout
                val stderr = executionResult.stderr

                buildTestResultsForChunk(
                    testToExpectedFilesMap,
                    adjustedTestCopyToExpectedFilesMap,
                    execCmd,
                    stdout,
                    stderr
                )
            }
            .flatten()
    }

    /**
     * Build additional flags which could be provided directly from text of test file
     *
     * @param testsPaths list of paths of the test files
     * @param batchSize
     * @return [ExtraFlags] instance
     */
    private fun buildExtraFlags(
        testsPaths: List,
        batchSize: Int,
    ): ExtraFlags {
        val extraFlagsList = testsPaths.mapNotNull { path -> extraFlagsExtractor.extractExtraFlagsFrom(path) }.distinct()
        require(extraFlagsList.size <= 1) {
            "Extra flags for all files in a batch should be same, but you have batchSize=$batchSize" +
                    " and there are ${extraFlagsList.size} different sets of flags inside it, namely $extraFlagsList"
        }
        val extraFlags = extraFlagsList.singleOrNull() ?: ExtraFlags("", "")
        return extraFlags
    }

    /**
     * Build [execCmd] according provided configuration
     *
     * @param generalConfig
     * @param fixPluginConfig
     * @param testCopyToExpectedFilesMap list of paths to the copy of tests files, which will be modificated
     * @param batchSeparator
     * @param extraFlags
     * @return execution command
     */
    private fun buildExecCmd(
        generalConfig: GeneralConfig,
        fixPluginConfig: FixPluginConfig,
        testCopyToExpectedFilesMap: List,
        batchSeparator: String,
        extraFlags: ExtraFlags
    ): String {
        val testsCopyNames = testCopyToExpectedFilesMap.joinToString(separator = batchSeparator) { (testCopy, _) -> testCopy.toString() }
        val execFlags = fixPluginConfig.execFlags
        val execFlagsAdjusted = resolvePlaceholdersFrom(execFlags, extraFlags, testsCopyNames)
        val execCmdWithoutFlags = generalConfig.execCmd
        val execCmd = "$execCmdWithoutFlags $execFlagsAdjusted"
        return execCmd
    }

    /**
     * In this case fixes would be provided by sarif library, which will extract appropriate fixes from SARIF file
     *
     * @param executionResultStdout sarif report data: if sarif file is not provided,
     * supposed, that sarif report is present in stdout of execution result
     * @param fixPluginConfig fix plugin configuration
     * @param testsPaths path to tests file, which need to be modified
     * @param testCopyToExpectedFilesMap list of paths to the copy of tests files, which will be replaced by modified files
     * @return updated list of test files copies
     */
    private fun applyFixesFromSarif(
        executionResultStdout: List,
        fixPluginConfig: FixPluginConfig,
        testsPaths: List,
        testCopyToExpectedFilesMap: List,
    ): List {
        val tmpDirName = "${FixPlugin::class.simpleName!!}-${Random.nextInt()}".toPath()
        val (sarifFile, isSarifFileEmulated) = getSarifFile(executionResultStdout, fixPluginConfig, testsPaths, tmpDirName)

        // Fixes weren't performed by tool into the test files directly,
        // instead, there was created sarif file with list of fixes, which we will apply ourselves
        val fixedFiles = SarifFixAdapter(
            sarifFile = sarifFile,
            targetFiles = testsPaths,
            testRoot = testConfig.getRootConfig().directory,
        ).process()

        // sarif file was created by us, remove tmp data
        if (isSarifFileEmulated) {
            fs.deleteRecursively(tmpDirName, mustExist = true)
        }

        // modify existing map, replace test copies to fixed test copies
        val fixedTestCopyToExpectedFilesMap = testCopyToExpectedFilesMap.toMutableList().map { (testCopy, expected) ->
            val fixedTestCopy = fixedFiles.first {
                isComparingTestAndCopy(
                    it,
                    testCopy
                )
            }
            fixedTestCopy to expected
        }
        return fixedTestCopyToExpectedFilesMap
    }

    @Suppress("SAY_NO_TO_VAR")
    private fun getSarifFile(
        executionResultStdout: List,
        fixPluginConfig: FixPluginConfig,
        testsPaths: List,
        tmpDirName: Path,
    ): Pair {
        var isSarifFileEmulated = false

        val sarifFile = if (fixPluginConfig.actualFixSarifFileName != null) {
            // get provided sarif file
            calculatePathToSarifFile(
                sarifFileName = fixPluginConfig.actualFixSarifFileName,
                // Since we have one .sarif file for all tests, just take the first of them as anchor for calculation of paths
                anchorTestFilePath = testsPaths.first()
            )
        } else {
            // TODO: This case should be actually covered by tests too,
            // TODO: however, need to find analysis tool which create sarif report with fixes in stdout
            // sarif report is passed via stdout of executed tool
            // since sarif-utils lib need a real sarif file for processing,
            // emulate it here
            isSarifFileEmulated = true
            createTempDir(tmpDirName)
            val sarifFile = fs.createFile(tmpDirName / "emulated-sarif-report.sarif")
            fs.write(sarifFile) {
                writeUtf8(executionResultStdout.joinToString("\n"))
            }
            sarifFile
        }

        return sarifFile to isSarifFileEmulated
    }

    /**
     * For each chunk, build test results
     *
     * @param testToExpectedFilesMap list of initial test files, necessary for proper report
     * @param testCopyToExpectedFilesMap list of fixed files
     * @param execCmd execution command for debug info
     * @param stdout std out of executed tool, if any
     * @param stderr std err of executed tool, if any
     * @return list of the test results
     */
    private fun buildTestResultsForChunk(
        testToExpectedFilesMap: List,
        testCopyToExpectedFilesMap: List,
        execCmd: String,
        stdout: List,
        stderr: List
    ): List = testCopyToExpectedFilesMap.map { (testCopy, expected) ->
        val fixedLines = fs.readLines(testCopy)
        val expectedLines = fs.readLines(expected)
        val test = testToExpectedFilesMap.first { (test, _) ->
            isComparingTestAndCopy(test, testCopy)
        }.first
        TestResult(
            FixTestFiles(test, expected),
            checkStatus(expectedLines, fixedLines),
            DebugInfo(
                execCmd,
                stdout.filter { it.contains(testCopy.name) }.joinToString("\n"),
                stderr.filter { it.contains(testCopy.name) }.joinToString("\n"),
                null,
                CountWarnings.notApplicable,
            )
        )
    }

    private fun isComparingTestAndCopy(
        test: Path,
        testCopy: Path,
    ): Boolean {
        val testPath = test.trimTmpDir().trimTestRootPath().replaceSeparators()
        val testCopyPath = testCopy.trimTmpDir().trimTestRootPath().replaceSeparators()
        return testCopyPath.compareTo(testPath) == 0
    }

    private fun Path.trimTmpDir(): Path {
        val currentPathAdjusted = this.toString().replaceSeparators()
        val tmpDirAdjusted = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.toString().replaceSeparators()
        return if (currentPathAdjusted.startsWith(tmpDirAdjusted)) {
            currentPathAdjusted
                // trim tmpDir
                .substringAfter("$tmpDirAdjusted/")
                // trim tmp FixPlugin dir
                .substringAfter("/")
                .toPath()
        } else {
            this
        }
    }

    private fun Path.trimTestRootPath(): Path {
        val currentPathAdjusted = this.toString().replaceSeparators()
        val testRootPathAdjusted = testConfig.getRootConfig()
            .directory
            .toString()
            .replaceSeparators()
        return if (currentPathAdjusted.startsWith(testRootPathAdjusted)) {
            currentPathAdjusted
                .substringAfter("$testRootPathAdjusted/")
                .toPath()
        } else {
            this
        }
    }

    private fun String.replaceSeparators(): String = this.replace("\\", "/")

    private fun Path.replaceSeparators(): Path = this.toString().replaceSeparators().toPath()

    private fun failTestResult(
        chunk: List,
        ex: ProcessExecutionException,
        execCmd: String
    ) = chunk.map {
        TestResult(
            it,
            Fail(ex.describe(), ex.describe()),
            DebugInfo(execCmd, null, ex.message, null, CountWarnings.notApplicable)
        )
    }

    private fun checkStatus(expectedLines: List, fixedLines: List) =
            diff(expectedLines, fixedLines).let { patch ->
                if (patch.deltas.isEmpty()) {
                    Pass(null)
                } else {
                    Fail(patch.formatToString(), patch.formatToShortString())
                }
            }

    private fun createCopyOfTestFile(
        path: Path,
        generalConfig: GeneralConfig,
        fixPluginConfig: FixPluginConfig,
    ): Path {
        val pathCopy: Path = constructPathForCopyOfTestFile("${FixPlugin::class.simpleName!!}-${Random.nextInt()}", path)
        tmpDirectory = pathCopy.parent!!
        createTempDir(tmpDirectory!!)

        val defaultIgnoreLinesPatterns: MutableList = mutableListOf()
        generalConfig.expectedWarningsPattern?.let { defaultIgnoreLinesPatterns.add(it) }
        generalConfig.runConfigPattern?.let { defaultIgnoreLinesPatterns.add(it) }

        fs.write(fs.createFile(pathCopy)) {
            fs.readLines(path)
                .filter { line ->
                    fixPluginConfig.ignoreLines?.let {
                        fixPluginConfig.ignoreLinesPatterns.none { it.matches(line) }
                    }
                        ?: run {
                            defaultIgnoreLinesPatterns.none { it.matches(line) }
                        }
                }
                .forEach {
                    write(
                        (it + "\n").encodeToByteArray()
                    )
                }
        }
        return pathCopy
    }

    override fun rawDiscoverTestFiles(resourceDirectories: Sequence): Sequence {
        val fixPluginConfig = testConfig.pluginConfigs.filterIsInstance().single()
        val regex = fixPluginConfig.resourceNamePattern
        val resourceNameTest = fixPluginConfig.resourceNameTest
        val resourceNameExpected = fixPluginConfig.resourceNameExpected
        return resourceDirectories
            .map { fs.list(it) }
            .flatMap { files ->
                files.groupBy {
                    val matchResult = (regex).matchEntire(it.name)
                    matchResult?.groupValues?.get(1)  // this is a capture group for the start of file name
                }
                    .filter { it.value.size > 1 && it.key != null }
                    .mapValues { (name, group) ->
                        require(group.size == 2) { "Files should be grouped in pairs, but for name $name these files have been discovered: $group" }
                        FixTestFiles(
                            group.first { it.name.contains("$resourceNameTest.") },
                            group.first { it.name.contains("$resourceNameExpected.") },
                        )
                    }
                    .values
            }
    }

    override fun cleanupTempDir() {
        tmpDirectory?.also {
            if (fs.exists(it)) {
                fs.myDeleteRecursively(it)
            }
        }
    }

    private fun Patch.formatToString() = deltas.joinToString("\n") { delta ->
        when (delta) {
            is ChangeDelta -> diffGenerator
                .generateDiffRows(delta.source.lines, delta.target.lines)
                .joinToString(prefix = "ChangeDelta, position ${delta.source.position}, lines:\n", separator = "\n\n") {
                    """-${it.oldLine}
                      |+${it.newLine}
                      |""".trimMargin()
                }
            else -> delta.toString()
        }
    }

    private fun Patch.formatToShortString(): String = deltas.groupingBy {
        it.type
    }
        .aggregate, DeltaType, Int> { _, acc, delta, _ ->
            (acc ?: 0) + delta.source.lines.size
        }
        .toList()
        .joinToString { (type, lines) -> "$type: $lines lines" }

    /**
     * @property test test file
     * @property expected expected file
     */
    @Serializable
    data class FixTestFiles(
        @Serializable(with = PathSerializer::class) override val test: Path,
        @Serializable(with = PathSerializer::class) val expected: Path
    ) : TestFiles {
        override fun withRelativePaths(root: Path) = copy(
            test = test.createRelativePathToTheRoot(root).toPath(),
            expected = expected.createRelativePathToTheRoot(root).toPath(),
        )

        companion object {
            /**
             * @param builder `PolymorphicModuleBuilder` to which this class should be registered for serialization
             */
            fun register(builder: PolymorphicModuleBuilder): Unit =
                    builder.subclass(FixTestFiles::class)
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy