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

org.suecarter.tablediff.StringTableDiff.scala Maven / Gradle / Ivy

The newest version!
package org.suecarter.tablediff

import scala.annotation.tailrec

object StringTableDiff {

  import TableDiff._
  import ReportContent._

  /**
    *
    * @param report
    * @return String representation of the Report
    */
  def diffReportToString(report: ReportContent[_, _, _]) =
    if (report.isEmpty) {
      "Empty report"
    } else {
      // take a report section and turn it into a ReportSection with all entries being ValueDiffs
      // the flatten fns take ValueDiff ReportSections
      def valueDiffise[T](reportSection: ReportSection[T]): ReportSection[ValueDiff[T]] =
        reportSection.map(_.map { x =>
          val diffValue = x match {
            case diff: ValueDiff[T] @unchecked => diff
            case value                         => Right(Some(value))
          }
          diffValue
        })

      def charRepeat(c: Char, count: Int) = new String((0 until count).map(x => c).toArray)
      val diffReport: ReportContent[ValueDiff[Any], ValueDiff[Any], ValueDiff[Any]] =
        report.copy(
          rowHeaders = valueDiffise(report.rowHeaders),
          columnHeaders = valueDiffise(report.columnHeaders),
          mainData = valueDiffise(report.mainData),
          rowColumnHeaders = valueDiffise(report.rowColumnHeaders)
        )

      // If the cell contains a multi line string, then extend the report rows
      def extendMultiLine(rows: ReportSection[String]): ReportSection[String] =
        rows.flatMap { row =>
          val splitRows = row.map(_.split("\n").toSeq)
          val rowLength = splitRows.map(_.size).max
          TableDiff.pivotHeaders[String](splitRows.map(row => row ++ (row.size until rowLength).map(x => "")))
        }

      val columnHeaders = extendMultiLine(flattenColumnHeaders(diffReport).map(h => h.map(valueDiffRenderer(_))))
      val rows = extendMultiLine(flattenTableRows(diffReport).map(h => h.map(valueDiffRenderer(_))))

      def maxStringWidth(headers: ReportSection[String]): Seq[Int] = {
        @tailrec
        def inner(accumulator: Seq[Int], headers: ReportSection[String]): Seq[Int] =
          headers.filter(_.nonEmpty) match {
            case Nil => accumulator
            case hs  => inner(accumulator :+ hs.map(_.head.size).max, hs.map(_.tail))
          }
        inner(Seq(), headers)
      }

      val columnWidths = maxStringWidth(columnHeaders ++ rows)
      def horizontalLine(char: Char = '-') = {
        val (l, r) = columnWidths.splitAt(report.rowWidth)
        (if (l.isEmpty) "" else l.map(i => charRepeat(char, i)).mkString("+", "-", if (r.isEmpty) "+" else "")) +
          (if (r.isEmpty) "" else r.map(i => charRepeat(char, i)).mkString("+", "-", "+"))
      }
      def resizeRowCells(row: Seq[String]) =
        row.zipWithIndex.map {
          case (cell, i) => cell + charRepeat(' ', columnWidths(i) - cell.size)
        }.mkString("|", "|", "|") + {
          val sizeMissing = columnWidths.size - row.size
          if (sizeMissing > 0)
            (row.size until columnWidths.size).map(i => charRepeat(' ', columnWidths(i))).mkString("", "|", "|")
          else ""
        }

      // produce output string. Resizing cells as we go, to square off the grid
      (if (report.columnDepth > 0)
         horizontalLine('-') + "\n" + columnHeaders.map(resizeRowCells).mkString("", "\n", "\n")
       else "") +
        horizontalLine('-') + "\n" +
        (if (rows.nonEmpty) {
           rows.map(resizeRowCells).mkString("", "\n", "\n") +
             horizontalLine('-') + "\n"
         } else "")
    }

  // generate in-place style diffs
  def valueDiffRenderer[T](
      value: ValueDiff[T],
      valueRenderer: (T) => String = (x: T) => x.toString,
      missingLeft: String = "[-",
      addedLeft: String = "{+",
      sameLeft: String = "",
      missingRight: String = "-]",
      addedRight: String = "+}",
      sameRight: String = "",
      chunkSize: Option[Int] = None,
      complexityThreshold: Int = 4
  ) =
    value.fold(
      l => {
        val leftString = l.left.map(x => if (isEmpty(x)) "" else valueRenderer(x)).getOrElse("")
        val rightString = l.right.map(x => if (isEmpty(x)) "" else valueRenderer(x)).getOrElse("")

        val diffs = chunkSize
          .map(c => zipLongestCommonSubsequence(leftString, rightString, c))
          .getOrElse(zipLongestCommonSubsequence(leftString, rightString))
        val onlyNumericDiffs = {
          case class NumberState(
              digitYet: Boolean = false,
              digitsAndLetters: Boolean = false,
              decimalPointYet: Boolean = false,
              onlyNumerics: Boolean = false,
              numericMinusYet: Boolean = false
          )
          val diffElements = diffs.span(!_.hasANone)._2.reverse.span(!_.hasANone)._2
          diffElements
            .foldLeft(NumberState()) {
              (state, diffLoc) =>
                val s = diffLoc.value match {
                  case _ if state.digitsAndLetters => state // never be true once digitsAndLetters is true
                  case m if m == '-' && !state.digitYet =>
                    state.copy(numericMinusYet = true, digitYet = true, onlyNumerics = true)
                  case p if p == '.' && state.decimalPointYet => state.copy(onlyNumerics = false)
                  case p if p == '.'                          => state.copy(decimalPointYet = true)
                  case d if Character.isDigit(d)              => state.copy(digitYet = true, onlyNumerics = true)
                  case _                                      => state.copy(digitsAndLetters = true, onlyNumerics = false)
                }
                s
            }
            .onlyNumerics
        }
        // little helper class to manage the complexity of the diff, default being no complexity
        case class DiffComplex(value: String = "", complex: Int = 0) {
          def prependValue(pre: Char) = this.copy(value = s"${pre}${value}")
        }
        def decorate(xOpt: Option[DiffLocation[Char]], yOpt: Option[DiffLocation[Char]]) = {
          val typeChange = xOpt.map(x => x.locationType) != yOpt.map(y => y.locationType)
          if (typeChange) {
            val xDiff = xOpt
              .map(_.locationType match {
                case OnlyLeft  => DiffComplex(missingRight, 1)
                case OnlyRight => DiffComplex(addedRight, 1)
                case InBoth    => DiffComplex(sameRight, 0)
              })
              .getOrElse(DiffComplex())
            val yDiff = yOpt
              .map(_.locationType match {
                case OnlyLeft  => DiffComplex(missingLeft, 0) // for every open there's a close
                case OnlyRight => DiffComplex(addedLeft, 0) // so no need to add complexity
                case InBoth    => DiffComplex(sameLeft, 0)
              })
              .getOrElse(DiffComplex())
            DiffComplex(xDiff.value + yDiff.value, xDiff.complex + yDiff.complex)
          } else DiffComplex()
        }
        val inPlaceDiff =
          diffs.headOption.map(head => decorate(None, Some(head))).getOrElse(DiffComplex()) +:
            diffs
              .sliding(2)
              .map {
                case x :: y :: Nil => decorate(Some(x), Some(y)).prependValue(x.value)
                case _             => DiffComplex() // Do nothing here as it's caught by tailOption and headOption bits
              }
              .toList :+
            diffs.lastOption.map(tail => decorate(Some(tail), None).prependValue(tail.value)).getOrElse(DiffComplex())
        // somewhat arbitrary, but if the inplace diff is longer than a simple diff, just return the simple one
        if (onlyNumericDiffs || inPlaceDiff.map(_.complex).sum >= complexityThreshold)
          l.left.map(x => if (isEmpty(x)) "" else missingLeft + valueRenderer(x) + missingRight).getOrElse("") +
            l.right.map(x => if (isEmpty(x)) "" else addedLeft + valueRenderer(x) + addedRight).getOrElse("")
        else
          inPlaceDiff.map(_.value).mkString
      },
      r => r.map(sameLeft + _.toString + sameRight).getOrElse("")
    )

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy