mergiraf 0.19.0

A syntax-aware merge driver for Git
Documentation
use crate::{MergeResult, ast::AstNode, pcs::Revision};
use diffy_imara::{Algorithm, ConflictStyle, MergeOptions};
use typed_arena::Arena;

use crate::{lang_profile::LangProfile, parsed_merge::ParsedMerge, settings::DisplaySettings};
pub const LINE_BASED_METHOD: &str = "line_based";

/// Returns a line-based merge in the form of a [ParsedMerge] object,
/// representing the merged parts and conflicting parts in a structured way.
///
/// TODO: ideally, [diffy_imara] would already expose such an interface,
/// to avoid rendering the merge to a large string and parsing it back
///
/// ## Panics
///
/// This will most likely panic if one of the input sides already
/// contains conflicts
pub fn line_based_merge_parsed(
    contents_base: &str,
    contents_left: &str,
    contents_right: &str,
    settings: &DisplaySettings,
) -> ParsedMerge<'static> {
    line_based_merge_parsed_checked(contents_base, contents_left, contents_right, settings)
        .expect("diffy-imara returned a merge that we cannot parse the conflicts of")
}

/// A version of [`line_based_merge_parsed`] that returns `None` instead of panicking.
///
/// Only really used for testing.
fn line_based_merge_parsed_checked(
    contents_base: &str,
    contents_left: &str,
    contents_right: &str,
    settings: &DisplaySettings,
) -> Option<ParsedMerge<'static>> {
    let merged = MergeOptions::new()
        .set_conflict_marker_length(settings.conflict_marker_size_or_default())
        .set_conflict_style(if settings.diff3 {
            ConflictStyle::Diff3
        } else {
            ConflictStyle::Merge
        })
        .set_algorithm(Algorithm::Histogram)
        .merge(contents_base, contents_left, contents_right);
    let merged_contents = match merged {
        Ok(contents) | Err(contents) => contents.leak(),
    };
    ParsedMerge::parse(merged_contents, settings).ok()
}

/// Perform a textual merge with the diff3 algorithm.
pub fn line_based_merge(
    contents_base: &str,
    contents_left: &str,
    contents_right: &str,
    settings: &DisplaySettings,
) -> MergeResult {
    let parsed_merge =
        line_based_merge_parsed(contents_base, contents_left, contents_right, settings);

    parsed_merge.into_merge_result(settings)
}

/// Do a line-based merge. If it is conflict-free, also check if it introduced any duplicate signatures,
/// in which case this is logged as an additional issue on the merge result.
pub(crate) fn line_based_merge_with_duplicate_signature_detection(
    contents_base: &str,
    contents_left: &str,
    contents_right: &str,
    settings: &DisplaySettings,
    lang_profile: &LangProfile,
) -> (ParsedMerge<'static>, MergeResult) {
    let parsed_merge =
        line_based_merge_parsed(contents_base, contents_left, contents_right, settings);

    let mut merge_result = parsed_merge.into_merge_result(settings);

    let revision_has_issues = |contents: &str| {
        let arena = Arena::new();
        let ref_arena = Arena::new();

        let tree = AstNode::parse(contents, lang_profile, &arena, &ref_arena);

        tree.map_or(true, AstNode::has_signature_conflicts)
    };

    merge_result.has_additional_issues = if merge_result.conflict_count == 0 {
        revision_has_issues(&merge_result.contents)
    } else {
        [Revision::Base, Revision::Left, Revision::Right]
            .into_iter()
            .map(|rev| parsed_merge.reconstruct_revision(rev))
            .any(|contents| revision_has_issues(&contents))
    };

    (parsed_merge, merge_result)
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn some_reconstructed_revisions_do_not_parse() {
        let contents_base = r#"import "github.com/go-redis/redis/v8"

func foo(){}"#;

        let contents_left = r#"import "github.com/redis/go-redis/v9"

func foo(){}"#;

        let contents_right = r#"import (
	"fmt"
	"net"
	"net/url"

	"github.com/redis/go-redis/v9"
)

// a comment to split hunks
func foo(){}"#;

        let contents_expected = r#"<<<<<<< LEFT
import "github.com/redis/go-redis/v9"
||||||| BASE
import "github.com/go-redis/redis/v8"
=======
import (
	"fmt"
	"net"
	"net/url"
>>>>>>> RIGHT

	"github.com/redis/go-redis/v9"
)

// a comment to split hunks
func foo(){}"#;

        let lang_profile = LangProfile::go();

        let (_, merge) = line_based_merge_with_duplicate_signature_detection(
            contents_base,
            contents_left,
            contents_right,
            &DisplaySettings::default(),
            lang_profile,
        );

        assert_eq!(&merge.contents, contents_expected);

        assert!(
            merge.has_additional_issues,
            "left and base reconstructed revisions shouldn't parse"
        );
    }

    #[test]
    fn line_based_merge_parsed_with_conflict_in_input() {
        let contents_base = "\
/**
 * Doc comment
 */
class MyClass {
}";
        let contents_left_7 = "\
/**
<<<<<<< HEAD
 * Doc comment
=======
 * Better docs
>>>>>>> origin/main
 */
class MyClass {
}";
        let contents_left_9 = "\
/**
<<<<<<<<< HEAD
 * Doc comment
=========
 * Better docs
>>>>>>>>> origin/main
 */
class MyClass {
}";

        let contents_right = "\
/**
 * Doc comment
 */
class MyClass {
}

class OtherClass {
}";

        let merge = |contents_left, settings| {
            line_based_merge_parsed_checked(contents_base, contents_left, contents_right, settings)
                .map(|parsed| parsed.render(settings))
        };

        let settings_7 = &DisplaySettings::default();
        let settings_9 = &DisplaySettings::from_conflict_marker_size(9);

        // These two won't work because the pre-existing conflict will screw up parsing.
        assert_eq!(
            merge(contents_left_7, settings_7),
            None,
            "conflict of standard size + settings with standard size"
        );
        assert_eq!(
            merge(contents_left_9, settings_9),
            None,
            "conflict of size 9 + settings with size 9"
        );

        // These two will work because the parser won't notice the pre-existing conflict, as they
        // are of a different size as what it's looking for.
        insta::assert_snapshot!(
            merge(contents_left_7, settings_9).unwrap(),
            "conflicts of standard size + settings with size ",
            @r"
/**
<<<<<<< HEAD
 * Doc comment
=======
 * Better docs
>>>>>>> origin/main
 */
class MyClass {
}
class OtherClass {
}
        ",
        );
        insta::assert_snapshot!(
            merge(contents_left_9, settings_7).unwrap(),
            "should fail to parse with pre-existing conflicts of standard size + default settings",
            @r"
/**
<<<<<<<<< HEAD
 * Doc comment
=========
 * Better docs
>>>>>>>>> origin/main
 */
class MyClass {
}
class OtherClass {
}
        ",
        );
    }
}