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

com.sageserpent.kineticmerge.core.CodeMotionAnalysisExtension.scala Maven / Gradle / Ivy

package com.sageserpent.kineticmerge.core

import cats.{Eq, Order}
import com.sageserpent.kineticmerge.core.LongestCommonSubsequence.Sized
import com.sageserpent.kineticmerge.core.merge.of as mergeOf
import com.typesafe.scalalogging.StrictLogging
import monocle.syntax.all.*

import scala.collection.Searching
import scala.collection.immutable.MultiDict

object CodeMotionAnalysisExtension extends StrictLogging:

  /** Add merging capability to a [[CodeMotionAnalysis]].
    *
    * Not sure exactly where this capability should be implemented - is it
    * really a core part of the API for [[CodeMotionAnalysis]]? Hence the
    * extension as a temporary measure.
    */

  extension [Path, Element: Eq: Order](
      codeMotionAnalysis: CodeMotionAnalysis[Path, Element]
  )
    def merge: (
        Map[Path, MergeResult[Element]],
        MatchesContext[Section[Element]]#MoveDestinationsReport
    ) =
      import codeMotionAnalysis.matchesFor

      val matchesContext = MatchesContext(
        codeMotionAnalysis.matchesFor
      )

      import matchesContext.*

      given Eq[Section[Element]] with
        /** This is most definitely *not* [[Section.equals]] - we want to use
          * matching of content, as the sections are expected to come from
          * *different* sides. [[Section.equals]] is expected to consider
          * sections from different sides as unequal. 

If neither section is * involved in a match, fall back to comparing the contents; this is * vital for comparing sections that would have been part of a larger * match if not for that match not achieving the threshold size. */ override def eqv( lhs: Section[Element], rhs: Section[Element] ): Boolean = val bothBelongToTheSameMatches = matchesFor(lhs).intersect(matchesFor(rhs)).nonEmpty bothBelongToTheSameMatches || Eq[Seq[Element]] .eqv(lhs.content, rhs.content) end eqv end given val paths = codeMotionAnalysis.base.keySet ++ codeMotionAnalysis.left.keySet ++ codeMotionAnalysis.right.keySet case class InsertionsAtPath(path: Path, insertions: Seq[Insertion]) def resolution( baseSection: Option[Section[Element]], leftSection: Section[Element], rightSection: Section[Element] ): Section[Element] = baseSection.fold(ifEmpty = // Break the symmetry - choose the left. leftSection ) { payload => // Look at the content and use *exact* comparison. val lhsIsCompletelyUnchanged = payload.content == leftSection.content val rhsIsCompletelyUnchanged = payload.content == rightSection.content (lhsIsCompletelyUnchanged, rhsIsCompletelyUnchanged) match case (false, true) => leftSection case (true, false) => rightSection case _ => // Break the symmetry - choose the left. leftSection end match } val ( mergeResultsByPath, changesMigratedThroughMotion, moveDestinationsReport, insertionsAtPath, oneSidedDeletions ) = paths.foldLeft( Map.empty[Path, MergeResult[Section[Element]]], Iterable.empty[ ( Section[Element], IndexedSeq[Section[Element]] ) ], emptyReport, Vector.empty[InsertionsAtPath], Set.empty[Section[Element]] ) { case ( ( mergeResultsByPath, changesMigratedThroughMotion, moveDestinationsReport, insertionsAtPath, oneSidedDeletions ), path ) => val base = codeMotionAnalysis.base.get(path).map(_.sections) val left = codeMotionAnalysis.left.get(path).map(_.sections) val right = codeMotionAnalysis.right.get(path).map(_.sections) (base, left, right) match case (None, Some(leftSections), None) => // File added only on the left; pass through as there is neither // anything to merge nor any sources of edits or deletions... ( mergeResultsByPath + (path -> FullyMerged( leftSections )), changesMigratedThroughMotion, leftSections.foldLeft(moveDestinationsReport)( _.leftMoveOf(_) ), insertionsAtPath, oneSidedDeletions ) case (None, None, Some(rightSections)) => // File added only on the right; pass through as there is // neither anything to merge nor any sources of edits or // deletions... ( mergeResultsByPath + (path -> FullyMerged( rightSections )), changesMigratedThroughMotion, rightSections.foldLeft(moveDestinationsReport)( _.rightMoveOf(_) ), insertionsAtPath, oneSidedDeletions ) case ( optionalBaseSections, optionalLeftSections, optionalRightSections ) => // Mix of possibilities - the file may have been added on both // sides, or modified on either or both sides, or deleted on one // side and modified on the other, or deleted on one or both // sides. There is also an extraneous case where there is no // file on any of the sides, and another extraneous case where // the file is on all three sides but hasn't changed. // Whichever is the case, merge... given Sized[Section[Element]] = _.size val mergedSectionsResult : MergeResultDetectingMotion[MergeResult, Section[ Element ]] = mergeOf(mergeAlgebra = MergeResultDetectingMotion.mergeAlgebra( coreMergeAlgebra = MergeResult.mergeAlgebra(resolution), resolution ) )( base = optionalBaseSections.getOrElse(IndexedSeq.empty), left = optionalLeftSections.getOrElse(IndexedSeq.empty), right = optionalRightSections.getOrElse(IndexedSeq.empty) ) ( mergeResultsByPath + (path -> mergedSectionsResult.coreMergeResult), changesMigratedThroughMotion ++ mergedSectionsResult.changesMigratedThroughMotion, moveDestinationsReport.mergeWith( mergedSectionsResult.moveDestinationsReport ), insertionsAtPath :+ InsertionsAtPath( path, mergedSectionsResult.insertions ), oneSidedDeletions union mergedSectionsResult.oneSidedDeletions ) end match } // NOTE: have to delay this assumption check until after the complete move // destination report has been finalized, and *not* make it an invariant // of `MoveDestination`. This is because divergent moves are entered as // separate left- and right-moves, so any such invariant could (and does) // break. moveDestinationsReport.moveDestinationsByMatches.values.foreach { moveDestination => if moveDestination.isDegenerate then // We don't consider left- and right-insertions to be degenerate // moves, as there is no match involved. assume( moveDestination.isDivergent || moveDestination.coincident.nonEmpty ) end if } def isMoveDestinationOnGivenSide( section: Section[Element], side: Side, moveDestinations: MoveDestinations[Section[Element]] ) = side match case Side.Left => moveDestinations.left.contains( section ) || moveDestinations.coincident.exists { case (leftPart, _) => section == leftPart } case Side.Right => moveDestinations.right .contains(section) || moveDestinations.coincident.exists { case (_, rightPart) => section == rightPart } given sectionRunOrdering[Sequence[Item] <: Seq[Item]] : Ordering[Sequence[Section[Element]]] = Ordering.Implicits.seqOrdering( Ordering.by[Section[Element], IndexedSeq[Element]](_.content)( Ordering.Implicits.seqOrdering(summon[Eq[Element]].toOrdering) ) ) given insertionSpliceOrdering: Ordering[InsertionSplice] = Ordering .by[InsertionSplice, Seq[Section[Element]]](_.insertions) .orElseBy(_.numberOfSkipsToTheAnchor) def uniqueItemsFrom[Item]( items: collection.Set[Item] )(using itemOrdering: Ordering[Item]): List[Item] = require(items.nonEmpty) val migratedChangesSortedByContent = items.toSeq.sorted(itemOrdering) val result = migratedChangesSortedByContent.tail.foldLeft( List(migratedChangesSortedByContent.head) ) { case (partialResult @ head :: _, change) => if 0 == itemOrdering.compare(head, change) then partialResult else change :: partialResult } assume(result.nonEmpty) result end uniqueItemsFrom enum Anchoring: case Predecessor case Successor end Anchoring case class InsertionSplice( insertions: Seq[Section[Element]], numberOfSkipsToTheAnchor: Int ): require(0 <= numberOfSkipsToTheAnchor) end InsertionSplice val migratedInsertionSplicesByAnchorDestinations : MultiDict[(Section[Element], Anchoring), InsertionSplice] = MultiDict.from(insertionsAtPath.flatMap { case InsertionsAtPath(path, insertions) => case class InsertionRun( side: Side, contiguousInsertions: Seq[Section[Element]] ): require(contiguousInsertions.nonEmpty) end InsertionRun val (partialResult, insertionRun) = insertions .foldLeft( Vector.empty[InsertionRun], None: Option[InsertionRun] ) { case ( ( partialResult, insertionRun ), Insertion(side, inserted) ) => insertionRun match case Some(InsertionRun(previousSide, previouslyInserted)) if previousSide == side && previouslyInserted.last.onePastEndOffset == inserted.startOffset => partialResult -> insertionRun .focus(_.some.contiguousInsertions) .modify(_ :+ inserted) case _ => (partialResult ++ insertionRun) -> Some( InsertionRun( side = side, contiguousInsertions = Vector(inserted) ) ) } val insertionRuns = partialResult ++ insertionRun // NOTE: the same insertion may not only be associated with multiple // anchor destinations due to ambiguous matches; it may also be // flanked on either side by anchor destinations. Hence the use of // `Anchoring` to track whether the anchor precedes or succeeds the // insertion. insertionRuns.flatMap { case InsertionRun(side, contiguousInsertions) => val file = side match case Side.Left => codeMotionAnalysis.left(path) case Side.Right => codeMotionAnalysis.right(path) { enum AnchorTestResult: case Found(destinations: collection.Set[Section[Element]]) case StopLooking case SkipOverAndKeepLooking end AnchorTestResult def testForAnchor( potentialAnchor: Section[Element] ): AnchorTestResult = val matches = matchesFor(potentialAnchor) moveDestinationsReport.moveDestinationsByMatches .get(matches) .fold(ifEmpty = if oneSidedDeletions.contains(potentialAnchor) then // If the potential anchor is a one-sided deletion, // then it isn't an anchor; however the lack of an // edit in the same file means we can think of it as // noise that doesn't affect the validity of a // subsequent anchor. AnchorTestResult.SkipOverAndKeepLooking else // There is an edit on the other side of the potential // anchor in the same file, this definitely isn't an // anchor in itself and will hem in any insertions // from the possibility of a subsequent anchor. AnchorTestResult.StopLooking )(moveDestinations => if !isMoveDestinationOnGivenSide( potentialAnchor, side, moveDestinations ) then AnchorTestResult.Found(side match case Side.Left => moveDestinations.right case Side.Right => moveDestinations.left ) else AnchorTestResult.StopLooking ) end testForAnchor val Searching.Found(indexOfLeadingInsertedSection) = file.searchByStartOffset( contiguousInsertions.head.startOffset ): @unchecked val ( predecessorAnchorDestinations, numberOfSkipsToPredecessor ) = val (skipped, remainder) = file.sections .take(indexOfLeadingInsertedSection) .view .reverse .map(testForAnchor) .span(AnchorTestResult.SkipOverAndKeepLooking == _) remainder.headOption .collect { case AnchorTestResult.Found(destinations) => destinations } .getOrElse(Set.empty) -> skipped.size end val val onePastIndex = contiguousInsertions.size + indexOfLeadingInsertedSection val (successorAnchorDestinations, numberOfSkipsToSuccessor) = val (skipped, remainder) = file.sections .drop(onePastIndex) .view .map(testForAnchor) .span(AnchorTestResult.SkipOverAndKeepLooking == _) remainder.headOption .collect { case AnchorTestResult.Found(destinations) => destinations } .getOrElse(Set.empty) -> skipped.size end val predecessorAnchorDestinations .map( _ -> Anchoring.Predecessor ) .map( _ -> InsertionSplice( contiguousInsertions, numberOfSkipsToPredecessor ) ) ++ successorAnchorDestinations .map(_ -> Anchoring.Successor) .map( _ -> InsertionSplice( contiguousInsertions, numberOfSkipsToSuccessor ) ) } } }) val migratedInsertions = migratedInsertionSplicesByAnchorDestinations.values .flatMap(_.insertions) .toSet val suppressedMoveDestinationsDueToMigratedInsertions = migratedInsertions.flatMap { insertion => val matches = matchesFor(insertion) moveDestinationsReport.moveDestinationsByMatches .get(matches) .fold(ifEmpty = Set.empty[Section[Element]])(_.all) } def applyMigrations( path: Path, mergeResult: MergeResult[Section[Element]] ): (Path, MergeResult[Section[Element]]) = val potentialValidDestinationsForMigratingChangesTo = moveDestinationsReport.moveDestinationsByMatches.values .filterNot(moveDestinations => moveDestinations.isDegenerate || moveDestinations.isDivergent ) .flatMap(moveDestinations => // NOTE: coincident move destinations can't pick up edits as there // would be no side to contribute the edit; instead, both of them // would contribute a move. moveDestinations.left ++ moveDestinations.right ) .toSet val vettedChangesMigratedThroughMotion = MultiDict.from(changesMigratedThroughMotion.filter { case (potentialDestination, _) => potentialValidDestinationsForMigratingChangesTo.contains( potentialDestination ) }) def substituteFor( section: Section[Element] ): IndexedSeq[Section[Element]] = val migratedChanges = vettedChangesMigratedThroughMotion .get(section) if migratedChanges.nonEmpty then val uniqueMigratedChanges = uniqueItemsFrom(migratedChanges) val migratedChange: IndexedSeq[Section[Element]] = uniqueMigratedChanges match case head :: Nil => head case _ => throw new RuntimeException( s""" |Multiple potential changes migrated to destination: $section, |these are: |${uniqueMigratedChanges .map(change => if change.isEmpty then "DELETION" else s"EDIT: $change" ) .zipWithIndex .map((change, index) => s"${1 + index}. $change") .mkString("\n")} |These are from ambiguous matches of text with the destination. |Consider setting the command line parameter `--minimum-ambiguous-match-size` to something larger than ${section.size}. """.stripMargin ) if migratedChange.isEmpty then logger.debug( s"Applying migrated deletion to move destination: ${pprintCustomised(section)}." ) else logger.debug( s"Applying migrated edit into ${pprintCustomised(migratedChange)} to move destination: ${pprintCustomised(section)}." ) end if migratedChange else IndexedSeq(section) end if end substituteFor def removeMigratedInsertions( sections: IndexedSeq[Section[Element]] ): IndexedSeq[Section[Element]] = sections .filterNot(migratedInsertions.contains) .filterNot( suppressedMoveDestinationsDueToMigratedInsertions.contains ) extension (sections: IndexedSeq[Section[Element]]) private def appendMigratedInsertions( migratedInsertions: Seq[Section[Element]] ): IndexedSeq[Section[Element]] = if migratedInsertions.nonEmpty then if sections.nonEmpty then logger.debug( s"Applying migrated insertion of ${pprintCustomised(migratedInsertions)} after destination: ${pprintCustomised(sections.last)}." ) else logger.debug( s"Applying migrated insertion of ${pprintCustomised(migratedInsertions)} at the start." ) end if sections ++ migratedInsertions def migrateInsertionsAndApplySubstitutions( sections: IndexedSeq[Section[Element]] ): IndexedSeq[Section[Element]] = case class Deferrals( deferredInsertions: Seq[Section[Element]], numberOfSkipsToTheAnchorOrDeferredContent: Either[Int, Seq[ Section[Element] ]] ): numberOfSkipsToTheAnchorOrDeferredContent.left.foreach( numberOfSkipsToTheAnchor => require(0 <= numberOfSkipsToTheAnchor) ) end Deferrals object Deferrals: // TODO: use Chimney instead! def apply(insertionSplice: InsertionSplice): Deferrals = Deferrals( deferredInsertions = insertionSplice.insertions, numberOfSkipsToTheAnchorOrDeferredContent = Left(insertionSplice.numberOfSkipsToTheAnchor) ) end Deferrals val emptyContext: Deferrals = Deferrals( deferredInsertions = Seq.empty, numberOfSkipsToTheAnchorOrDeferredContent = Right(Seq.empty) ) sections .foldLeft( IndexedSeq .empty[Section[Element]] -> emptyContext ) { case ( (partialResult, anchorContext), candidateAnchorDestination ) => val precedingMigratedInsertionSplices = migratedInsertionSplicesByAnchorDestinations.get( candidateAnchorDestination -> Anchoring.Successor ) val precedingInsertionSplice = Option.when(precedingMigratedInsertionSplices.nonEmpty) { val uniqueInsertionSplices = uniqueItemsFrom(precedingMigratedInsertionSplices) uniqueInsertionSplices match case head :: Nil => head case _ => throw new RuntimeException( s""" |Multiple potential insertions migrated before destination: $candidateAnchorDestination, |these are: |${uniqueInsertionSplices .map(insertion => s"PRE-INSERTION: $insertion") .zipWithIndex .map((insertion, index) => s"${1 + index}. $insertion" ) .mkString("\n")} |These are from ambiguous matches of anchor text with the destination. |Consider setting the command line parameter `--minimum-ambiguous-match-size` to something larger than ${candidateAnchorDestination.size}. """.stripMargin ) end match } val succeedingMigratedInsertionSplices = migratedInsertionSplicesByAnchorDestinations.get( candidateAnchorDestination -> Anchoring.Predecessor ) val succeedingInsertionSplice = Option.when(succeedingMigratedInsertionSplices.nonEmpty) { val uniqueInsertionSplices = uniqueItemsFrom(succeedingMigratedInsertionSplices) uniqueInsertionSplices match case head :: Nil => head case _ => throw new RuntimeException( s""" |Multiple potential insertions migrated after destination: $candidateAnchorDestination, |these are: |${uniqueInsertionSplices .map(insertion => s"POST-INSERTION: $insertion") .zipWithIndex .map((insertion, index) => s"${1 + index}. $insertion" ) .mkString("\n")} |These are from ambiguous matches of anchor text with the destination. |Consider setting the command line parameter `--minimum-ambiguous-match-size` to something larger than ${candidateAnchorDestination.size}. """.stripMargin ) end match } precedingInsertionSplice.foreach(splice => logger.debug( s"Encountered succeeding anchor destination: ${pprintCustomised(candidateAnchorDestination)} with associated preceding insertion splice: ${pprintCustomised(splice)}." ) ) succeedingInsertionSplice.foreach(splice => logger.debug( s"Encountered preceding anchor destination: ${pprintCustomised(candidateAnchorDestination)} with associated following insertion splice: ${pprintCustomised(splice)}." ) ) val substituted = substituteFor(candidateAnchorDestination) ( anchorContext, precedingInsertionSplice, succeedingInsertionSplice ) match // NOTE: avoid use of lenses in the cases below when we // already have to pattern match deeply anyway... case ( Deferrals(deferredInsertions, Left(0)), None, None ) => // We have arrived at the insertion point after a preceding // anchor, but have to defer both the insertions and // the substitutions for `candidateAnchorDestination` (which // is not an anchor after all) in case of a following // succeeding anchor... partialResult -> Deferrals( deferredInsertions = deferredInsertions, numberOfSkipsToTheAnchorOrDeferredContent = Right(substituted) ) case ( Deferrals( deferredInsertions, Left(numberOfSkipsToTheAnchor) ), None, None ) => // Consider the substitutions // for`candidateAnchorDestination` (which is not an anchor // after all) to be an edit of a skipped section coming // after a preceding anchor. (partialResult ++ substituted) -> Deferrals( deferredInsertions, Left( numberOfSkipsToTheAnchor - 1 ) ) case ( Deferrals( deferredInsertions, Right(deferredContent) ), None, None ) => // We have to defer the substitutions // for`candidateAnchorDestination` (which is not an anchor // after all) in case of a following succeeding anchor... partialResult -> Deferrals( deferredInsertions, Right(deferredContent ++ substituted) ) case ( Deferrals( deferredInsertions, Left(_) ), Some(InsertionSplice(insertionsForSucceedingAnchor, _)), _ ) => // We have encountered a succeeding anchor; will this refer // to the same insertions as the context? if sectionRunOrdering.equiv( deferredInsertions, insertionsForSucceedingAnchor ) then // The implied preceding anchor and the succeeding anchor // just encountered bracket the same insertions. (partialResult.appendMigratedInsertions( deferredInsertions.flatMap(substituteFor) ) ++ substituted) -> succeedingInsertionSplice .fold(ifEmpty = emptyContext)(Deferrals.apply) else // The implied preceding anchor and the succeeding anchor // just encountered refer to distinct insertions that have // migrated adjacent to each other. As insertions try to // stick as close as possible to their anchor, we put // `deferredInsertionsForImpliedPrecedingAnchor` first. (partialResult.appendMigratedInsertions( deferredInsertions.flatMap( substituteFor ) ++ insertionsForSucceedingAnchor ) ++ substituted) -> succeedingInsertionSplice .fold(ifEmpty = emptyContext)(Deferrals.apply) case ( Deferrals( deferredInsertions, Right(deferredContent) ), Some(spliceIntoDeferredContext), _ ) => // We have encountered a succeeding anchor, so we use the // deferred context to decide where to place the insertions. val (prefix, suffix) = deferredContent.splitAt( deferredContent.length - spliceIntoDeferredContext.numberOfSkipsToTheAnchor ) if sectionRunOrdering.equiv( deferredInsertions, spliceIntoDeferredContext.insertions ) then // The implied preceding anchor and the succeeding anchor // just encountered bracket the same insertions; if there // were any skipped sections being treated as edits coming // after the preceding anchor, then we treat the prefix as // being greedily captured by the last such edit. (partialResult ++ prefix) .appendMigratedInsertions( spliceIntoDeferredContext.insertions ) ++ suffix ++ substituted -> succeedingInsertionSplice .fold(ifEmpty = emptyContext)(Deferrals.apply) else ((partialResult.appendMigratedInsertions( deferredInsertions.flatMap(substituteFor) ) ++ prefix).appendMigratedInsertions( spliceIntoDeferredContext.insertions ) ++ suffix ++ substituted) -> succeedingInsertionSplice .fold(ifEmpty = emptyContext)(Deferrals.apply) end if case ( Deferrals(deferredInsertions, Left(_)), None, Some(insertionSplice) ) => // We have encountered a preceding anchor, so the deferred // insertions from the previous preceding anchor can finally // be added to the partial result. (partialResult.appendMigratedInsertions( deferredInsertions.flatMap(substituteFor) ) ++ substituted) -> Deferrals( insertionSplice ) case ( Deferrals( deferredInsertions, Right(deferredContent) ), None, Some(insertionSplice) ) => // We have encountered a preceding anchor, so the deferred // insertions from the previous preceding anchor and the // deferred content can finally be added to the partial // result. (partialResult.appendMigratedInsertions( deferredInsertions.flatMap(substituteFor) ) ++ deferredContent ++ substituted) -> Deferrals( insertionSplice ) end match } match case (partialResult, anchorContext) => anchorContext match case Deferrals(deferredInsertions, Left(_)) => partialResult.appendMigratedInsertions(deferredInsertions) case Deferrals( deferredInsertions, Right(deferredContent) ) => partialResult.appendMigratedInsertions( deferredInsertions.flatMap(substituteFor) ) ++ deferredContent end match end migrateInsertionsAndApplySubstitutions path -> (mergeResult match case FullyMerged(sections) => FullyMerged(elements = migrateInsertionsAndApplySubstitutions( removeMigratedInsertions(sections) ) ) case MergedWithConflicts(leftSections, rightSections) => MergedWithConflicts( migrateInsertionsAndApplySubstitutions( removeMigratedInsertions(leftSections) ), migrateInsertionsAndApplySubstitutions( removeMigratedInsertions(rightSections) ) ) ) end applyMigrations def explodeSections( path: Path, mergeResult: MergeResult[Section[Element]] ): (Path, MergeResult[Element]) = path -> (mergeResult match case FullyMerged(sections) => FullyMerged(elements = sections.flatMap(_.content)) case MergedWithConflicts(leftSections, rightSections) => val leftElements = leftSections.flatMap(_.content) val rightElements = rightSections.flatMap(_.content) // Just in case the conflict was resolved by the migrated // changes... if leftElements.corresponds( rightElements )(summon[Eq[Element]].eqv) then FullyMerged(leftElements) else MergedWithConflicts( leftElements, rightElements ) end if ) end explodeSections mergeResultsByPath .map(applyMigrations) .map(explodeSections) -> moveDestinationsReport end merge end extension end CodeMotionAnalysisExtension





© 2015 - 2025 Weber Informatics LLC | Privacy Policy