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

com.potenciasoftware.formats.FormatWithContext.scala Maven / Gradle / Ivy

package com.potenciasoftware.formats

import scala.annotation.tailrec

import FormatWithContext._

/**
  * Given a source text and a sequence of contextual details tied to specific
  * positions in the source text, format the source text and include the context
  * details below each relevant line. The position in the source text and the
  * details will be connected with drawn lines.
  *
  * @param maxColumn
  *   The maximum column a line of text can reach before being wrapped
  * @param detailGrouping
  *   How many individual source positions to show context for. If there are
  *   more positions for a given line, that line will be repeated with the
  *   additional context shown.
  * @param blanksBetweenPositionPoints
  *   How many blank lines should be added between context points that all
  *   relate to the same position
  * @param blanksBetweenPositionGroups
  *   How many blank lines should be added between the detail groups of each
  *   position
  */
final case class FormatWithContext(
    maxColumn: Int = 80,
    detailGrouping: Int = 6,
    blanksBetweenPositionPoints: Int = 0,
    blanksBetweenPositionGroups: Int = 1,
) {

  /** Apply formatting to the input strings */
  def apply(
      text: String,
      context: Iterable[(Int, Iterable[String])],
  ): Iterable[String] = {

    val allContext = context
      .groupBy(_._1)
      .view.mapValues(_.map(_._2).toSeq.distinct)
      .toSeq.sortBy(_._1)

    implicit val format = ParagraphFormat(
      maxColumn = maxColumn,
      blanksBetweenLines = 0,
      trimLines = false,
    )

    val split = text.split("\n").toSeq
    val original = calcLinesAbsPosition((split.headOption.toSeq ++ split.tail.map(" " + _)).asParagraphs).toSeq
    (original.map(_._2) ++ allContext.lastOption.map(_._1 + 1).toSeq)
      .sliding(2).toSeq
      .zipWithIndex.flatMap { case (Seq(start, end), i) =>
        val text = original(i)._1
        val localContext = allContext
          .collect {
            case (pos, paragraphs) if pos >= start && pos < end =>
              (pos - start) -> paragraphs
          }

        val config = Config(
          maxColumn,
          blanksBetweenPositionPoints,
          blanksBetweenPositionGroups,
        )

        Some(localContext)
          .filter(_.nonEmpty).map(
            _.grouped(detailGrouping)
              .map(Details(_, config))
              .flatMap { details =>
                text +: (0 to (details.end)).map(details.apply)
              }
          )
          .getOrElse(Seq(text))
      }
  }

  /** An alias for apply(String, Iterable[(Int, Iterable[String])]) */
  def format(
      text: String,
      context: Iterable[(Int, Iterable[String])],
  ): Iterable[String] =
    apply(text, context)
}

object FormatWithContext {

  private type Paragraph = Iterable[String]
  private type Paragraphs = Iterable[Paragraph]
  private type Position = Int
  private type ContextualDetails = (Position, Paragraphs)

  case class Config(
      maxColumn: Int,
      blanksBetweenPositionPoints: Int,
      blanksBetweenPositionGroups: Int,
  )

  @tailrec
  private def calcLinesAbsPosition(input: Iterable[String], abs: Seq[Int] = Seq.empty): Iterable[(String, Int)] = {
    if (input.size == abs.size) input zip abs
    else {
      val view = input.view
      val prior = view.take(abs.size)
      val next = view.drop(abs.size)
      val previousPos = abs.lastOption
        .foldLeft(next.head.takeWhile(_.isWhitespace).size)(_ + _)
      calcLinesAbsPosition(
        prior.toSeq ++ (next.head.dropWhile(_.isWhitespace) +: next.tail.toSeq),
        abs :+ prior.lastOption.map(_.length).getOrElse(0) + previousPos,
      )
    }
  }

  private case class Details(details: Vector[Detail]) {

    private lazy val maxBeam: Int = details.flatMap(_.beams).max

    def apply(lineNum: Int): String = {
      val buf = Array.fill(maxBeam + 1)(' ')
      details.foreach(_.addCornersHorizAndBeam(lineNum, buf))
      val margin = buf.reverse.dropWhile(_ == ' ').reverse.mkString
      details
        .flatMap(_.contextLineAt(lineNum))
        .headOption
        .foldLeft(margin) {
          case (m, (beam, line)) => m.padTo(beam, ' ') + line
        }
    }

    lazy val end: Int = details
      .lastOption
      .map(d => d.paragraphsStart + d.allContext.size)
      .get
  }

  private object Details {

    def apply(details: Seq[ContextualDetails], config: Config) = {

      val initial = details
        .map(_._1)
        .sorted(Ordering[Int].reverse)
        .tails
        .zipWithIndex
        .map(Detail.create(details.size, config))
        .filter(_.nonEmpty)
        .toVector

      val withTurns = initial.sorted.zipWithIndex.map {
        case (l, i) => l.copy(turns = i +: l.turns)
      }

      val dataAdjusted = adjustLineData(withTurns)

      val withParagraphs =
        for {
          d <- dataAdjusted.sortBy(_.position)
          (_, paragraphs) <- details.find(_._1 == d.position)
        } yield d.addParagraphs(paragraphs)
      new Details(adjustParagraphsStart(withParagraphs, config))
    }

    @tailrec
    private def adjustLineData(dd: Vector[Detail]): Vector[Detail] = {
      val toFix = (
        for {
          (d, i) <- dd.view.zipWithIndex
          o <- dd.view.drop(i + 1)
          (collides, normal) = d collidesWith o
          if collides
        } yield (d, i, normal)
      ).headOption

      if (toFix.isEmpty) dd
      else {

        val Some((detail, i, normal)) = toFix: @unchecked
        val maxTurn = dd.flatMap(_.turns).max
        val collisionPoints =
          (i to maxTurn flatMap { lineToCheck =>
            dd.flatMap(_.collisionPointsAt(lineToCheck))
          }).toSet

        adjustLineData(if (normal) {
          val toMove = detail.beams.head
          val sign = if (detail.position > toMove) 1 else -1
          val available =
            ((0 to (dd.size * 2)).toSet -- collisionPoints).toSeq
              .map(_ - toMove)
              .sortBy(d => (Math.abs(d), Math.signum(d.toFloat) * sign))
          val adjutment = available.head
          val newLine = detail.copy(
            beams = Vector(toMove, (toMove + adjutment)) ++ detail.beams.tail,
            turns = detail.turns :+ (maxTurn + 1)
          )
          dd.updated(i, newLine)
        } else dd.map(l => l.copy(paragraphsStart = l.paragraphsStart + 1)))
      }
    }

    @tailrec
    private def adjustParagraphsStart(dd: Vector[Detail], config: Config, pos: Int = 0): Vector[Detail] = {

      val prior = dd.take(pos)
      val view = dd.view.drop(pos)
      val d = view.head
      val tail = view.tail

      val currentStart = d.paragraphsStart
      val potentialStart =
        prior.lastOption.map(_.end).getOrElse(0) + 1 + {
          if (pos == 0) 0
          else config.blanksBetweenPositionGroups
        }

      val target = currentStart max potentialStart max (d.turns.last + 1)
      val moveDetailsStart: Detail => Detail =
        d => d.copy(paragraphsStart = d.paragraphsStart + (target - currentStart))

      if (pos > dd.size - 2)
        prior :+ moveDetailsStart(d)
      else
        adjustParagraphsStart(prior ++ (moveDetailsStart(d) +: (tail.map(moveDetailsStart).toVector)), config, pos + 1)
    }
  }

  private case class Detail(
      positions: List[Int],
      beams: Vector[Int],
      turns: Vector[Int],
      paragraphsStart: Int,
      paragraphs: Paragraphs,
      config: Config,
  ) {

    lazy val nonEmpty = positions.nonEmpty

    lazy val position = positions.head
    lazy val otherPositions = positions.tail

    def addParagraphs(input: Paragraphs): Detail = {
      implicit val format =
        ParagraphFormat(
          maxColumn = config.maxColumn - beams.last - 3,
          blanksBetweenLines = 0,
        )

      copy(paragraphs = input.map(_.asParagraphs))
    }

    def cornersAt(lineNum: Int): Option[Seq[Int]] =
      Some(turns.indexOf(lineNum)).filter(_ > -1) map { turn =>
        beams.drop(turn) take (2)
      }

    def beamAt(lineNum: Int): Int =
      beams(turns.size - turns.dropWhile(lineNum > _).size)

    def collisionPointsAt(lineNum: Int): Set[Int] =
      (cornersAt(lineNum).toSeq.flatten :+ beamAt(lineNum)).toSet

    def collidesWith(other: Detail): (Boolean, Boolean) = {
      def maxTurn = (turns ++ other.turns).max
      if (
        (0 to maxTurn) exists { n =>
          (collisionPointsAt(n) intersect other.collisionPointsAt(n)).nonEmpty
        }
      ) (true, true)
      else {
        val specialCollision =
          (paragraphsStart to other.turns.max) exists { lineNum =>
            other.cornersAt(lineNum) exists { corners =>
              corners.exists(beams.last <= _)
            }
          }
        if (specialCollision) (true, false) else (false, false)
      }
    }

    def addCornersHorizAndBeam(lineNum: Int, buf: Array[Char]): Unit =
      cornersAt(lineNum) match {
        case Some(ft) =>
          val Seq(from, to) = ft
          if (from < to) {
            buf(from) = '╰'
            buf(to) = '╮'
            ((from + 1) until to).filter(buf(_) == ' ').foreach { buf(_) = '─' }
          } else if (to < from) {
            buf(from) = '╯'
            buf(to) = '╭'
            ((to + 1) until from).filter(buf(_) == ' ').foreach { buf(_) = '─' }
          } else buf(to) = '│'
        case None =>
          if (lineNum < paragraphsStart)
            buf(beamAt(lineNum)) = '│'
      }

    lazy val allContext = {
      val lastGraf = paragraphs.size - 1
      paragraphs.zipWithIndex flatMap { case (paragraph, i) =>
        if (i == lastGraf)
          paragraph.take(1).map("╰─ " + _) ++
            paragraph.drop(1).map("   " + _)
        else
          paragraph.take(1).map("├─ " + _) ++
            paragraph.drop(1).map("│  " + _) ++
            Seq.fill(config.blanksBetweenPositionPoints)("│")
      }
    }

    lazy val end: Int = paragraphsStart + allContext.size - 1

    def contextLineAt(lineNum: Int): Option[(Int, String)] =
      (lineNum match {
        case i if i < paragraphsStart => None
        case i => allContext.drop(i - paragraphsStart).headOption
      }).map(beams.last -> _)
  }

  private object Detail {

    def create(groupSize: Int, config: Config)(t: (Seq[Int], Int)): Detail =
      Detail(
        positions = t._1.toList,
        beams = t._1.headOption.toVector :+ (t._2 * 2 + 1),
        turns = Vector.empty,
        paragraphsStart = (groupSize - t._2) - 1,
        paragraphs = Nil,
        config = config,
      )

    implicit val ordering: Ordering[Detail] =
      new Ordering[Detail] {
        override def compare(x: Detail, y: Detail): Int = {
          if (y.beams.exists(_ < x.position)) 0 else -1
        }
      }.orElse(Ordering.by[Detail, Int](_.position).reverse)
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy