echoes 0.10.2

Find duplicated Rust doctests, functions and contexts
Documentation
//! Boolean grouping membership, canonical sizes and context views.

#[path = "support/dependency.rs"]
mod support;

#[path = "support/function.rs"]
mod functions;
#[path = "support/group_members.rs"]
mod group_members;
#[path = "support/impl_item.rs"]
mod impl_item;
#[path = "support/public_sites.rs"]
mod public_sites;
#[path = "support/site_src.rs"]
mod site_src;

use echoes::{Echoes, SourceFile, TargetScan, group};
use functions::function;
use group_members::members;
use impl_item::impl_method;
use public_sites::assert_public_sites;
use site_src::site;

const DOMAINS: [(&str, &str); 6] = [
    (
        "fn f(p: bool) -> bool { !!p }",
        "fn g(x: bool) -> bool { x }",
    ),
    (
        "fn f(p: bool) -> bool { if p { true } else { false } }",
        "fn g(x: bool) -> bool { x }",
    ),
    (
        "fn f(a: bool, b: bool) -> bool { !(a && b) }",
        "fn g(x: bool, y: bool) -> bool { !x || !y }",
    ),
    (
        "fn f(p: bool) -> bool { p == true }",
        "fn g(x: bool) -> bool { x }",
    ),
    (
        "fn f(p: bool) -> u32 { if !p { 1u32 } else { 2u32 } }",
        "fn g(x: bool) -> u32 { if x { 2u32 } else { 1u32 } }",
    ),
    (
        "fn f() -> u32 { if true { 5u32 } else { 6u32 } }",
        "fn g() -> u32 { 5u32 }",
    ),
];

#[test]
fn boolean_grouping_keeps_exact_passing_members_and_excludes_failing_variants() {
    for (left, right) in DOMAINS {
        let sites = [
            site("pass_a", left, &[]),
            site("pass_b", right, &[]),
            site("fail_a", left, &["compile_fail"]),
            site("fail_b", right, &["compile_fail"]),
        ];
        let report = group(&sites, 2, 0);
        assert_eq!(
            members(&report.groups),
            [vec!["pass_a", "pass_b"]],
            "{left}\n{right}"
        );
        assert!(report.groups[0].sites.iter().all(|member| {
            member.line == 7 && member.end == Some(11) && member.info.is_empty()
        }));
    }
}

#[test]
fn boolean_inherited_negatives_keep_four_singletons() {
    for case in support::cases().iter().filter(|case| {
        case.expected != "same"
            && case.family == "negative"
            && [
                "N12_short_circuit",
                "N14_temporary_drop_scope",
                "N15_discarded_branch_inference",
            ]
            .contains(&case.id.as_str())
    }) {
        let sites = [
            site("pass_a", &case.a, &[]),
            site("pass_b", &case.b, &[]),
            site("fail_a", &case.a, &["compile_fail"]),
            site("fail_b", &case.b, &["compile_fail"]),
        ];
        let report = group(&sites, 1, 0);
        assert_eq!(
            members(&report.groups),
            [
                vec!["fail_a"],
                vec!["fail_b"],
                vec!["pass_a"],
                vec!["pass_b"]
            ],
            "{}",
            case.id
        );
    }
}

#[test]
fn boolean_later_batch_controls_stay_separate_in_groups() {
    for (left, right) in [
        (
            "fn f() -> u32 { if true { mark(); 1u32 } else { 2u32 } }",
            "fn g() -> u32 { mark(); 1u32 }",
        ),
        (
            "fn f() -> u32 { if 1u32 == 2u32 { 3u32 } else { 4u32 } }",
            "fn g() -> u32 { 4u32 }",
        ),
    ] {
        let sites = [site("first", left, &[]), site("second", right, &[])];
        let report = group(&sites, 1, 0);
        assert_eq!(
            members(&report.groups),
            [vec!["first"], vec!["second"]],
            "{left}\n{right}"
        );
    }
}

#[test]
fn boolean_threshold_uses_one_canonical_size_in_both_site_orders() {
    for (left, right) in [
        (
            "fn f(p: bool) -> bool { if p { true } else { false } }",
            "fn g(x: bool) -> bool { x }",
        ),
        (
            "fn f(p: bool) -> bool { !!p }",
            "fn g(x: bool) -> bool { x }",
        ),
    ] {
        let a =
            syn_canon::canonicalize(syn::parse_str(&format!("fn main() {{ {left} }}")).unwrap());
        let b =
            syn_canon::canonicalize(syn::parse_str(&format!("fn main() {{ {right} }}")).unwrap());
        assert_eq!(a, b);
        assert_eq!(a.leaf_tokens(), b.leaf_tokens());
        assert_eq!(a.body_units(), b.body_units());
        let sites = [site("first", left, &[]), site("second", right, &[])];
        let reversed = [site("second", right, &[]), site("first", left, &[])];
        let included = group(&sites, 2, a.leaf_tokens());
        let reversed_report = group(&reversed, 2, a.leaf_tokens());
        assert_eq!(members(&included.groups), [vec!["first", "second"]]);
        assert_eq!(members(&reversed_report.groups), members(&included.groups));
        assert_eq!(included.groups[0].id, reversed_report.groups[0].id);
        assert_eq!(included.groups[0].tokens, a.leaf_tokens());
        assert_eq!(group(&sites, 2, a.leaf_tokens() + 1).groups.len(), 0);
    }
}

#[test]
fn boolean_function_views_preserve_contextual_relations() {
    let source: syn::File = syn::parse_str(
        "
        fn first(input: bool) -> bool { if input { true } else { false } }
        fn same(value: bool) -> bool { value }
        fn held(gate: bool) -> bool { !gate }
        struct Owner;
        impl Owner {
            fn inverted(p: bool) -> u32 { if !p { 1u32 } else { 2u32 } }
            fn flipped(x: bool) -> u32 { if x { 2u32 } else { 1u32 } }
            fn kept(p: bool) -> u32 { if !p { 1u32 } else { 3u32 } }
        }
        ",
    )
    .unwrap();
    let context = syn_canon::SourceContext::new(core::iter::once((&[][..], &source)));
    let canonical = |name: &str| {
        let function = function(&source, name);
        context
            .function(&function.sig, &function.block)
            .unwrap()
            .canonicalize()
    };
    assert_eq!(canonical("first"), canonical("same"));
    assert_ne!(canonical("first"), canonical("held"));
    let method = |name: &str| {
        let member = impl_method(&source, name);
        context
            .function(&member.sig, &member.block)
            .unwrap()
            .canonicalize()
    };
    assert_eq!(method("inverted"), method("flipped"));
    assert_ne!(method("inverted"), method("kept"));
}

struct SpanWiper;

impl syn::visit_mut::VisitMut for SpanWiper {
    fn visit_ident_mut(&mut self, ident: &mut syn::Ident) {
        ident.set_span(proc_macro2::Span::call_site());
    }

    fn visit_lit_mut(&mut self, lit: &mut syn::Lit) {
        lit.set_span(proc_macro2::Span::call_site());
    }
}

fn wiped_form(source: &str, compiles: bool) -> syn_canon::CanonicalForm {
    let mut file = syn::parse_str::<syn::File>(source).unwrap();
    syn::visit_mut::visit_file_mut(&mut SpanWiper, &mut file);
    if compiles {
        syn_canon::canonicalize(file)
    } else {
        syn_canon::canonicalize_failing(file)
    }
}

fn form(source: &str) -> syn_canon::CanonicalForm {
    syn_canon::canonicalize(syn::parse_str(source).unwrap())
}

#[test]
fn constructed_equal_spans_preserve_boolean_relations() {
    let plain = "fn f(p: bool) -> bool { !!p }";
    let folded = "fn g(x: bool) -> bool { x }";
    let held_a = "fn f(p: bool) -> bool { !p }";
    let held_b = "fn g(x: bool) -> bool { x }";
    assert_eq!(wiped_form(plain, true), form(plain));
    assert_eq!(wiped_form(plain, true), wiped_form(folded, true));
    assert_ne!(wiped_form(plain, false), wiped_form(folded, false));
    assert_eq!(wiped_form(held_a, true), form(held_a));
    assert_ne!(wiped_form(held_a, true), wiped_form(held_b, true));
}

fn negated(depth: usize) -> String {
    let mut expr = "a && b".to_owned();
    for _ in 0..depth {
        expr = format!("!({expr})");
    }
    format!("fn f(a: bool, b: bool) -> bool {{ {expr} }}")
}

#[test]
fn bounded_boolean_fallback_remains_atomic_and_deterministic() {
    let under = negated(48);
    let plain = "fn g(a: bool, b: bool) -> bool { a && b }";
    let a = syn_canon::canonicalize(syn::parse_str(&under).unwrap());
    let b = syn_canon::canonicalize(syn::parse_str(plain).unwrap());
    assert_eq!(a, b, "{under}\n{plain}");
    assert_eq!(a.body_units(), b.body_units());
    assert_eq!(a.leaf_tokens(), b.leaf_tokens());
    let over = negated(96);
    let over_first = syn_canon::canonicalize(syn::parse_str(&over).unwrap());
    let over_second = syn_canon::canonicalize(syn::parse_str(&over).unwrap());
    assert_ne!(over_first, b, "{over}\n{plain}");
    assert_ne!(
        syn_canon::canonicalize_failing(syn::parse_str(&over).unwrap()),
        syn_canon::canonicalize_failing(syn::parse_str(plain).unwrap())
    );
    assert_eq!(
        over_first, over_second,
        "the over-bound form is deterministic"
    );
    assert_eq!(over_first.leaf_tokens(), over_second.leaf_tokens());
    assert_eq!(over_first.body_units(), over_second.body_units());
    assert!(
        over_first.body_units() >= 96,
        "the whole over-bound region is retained"
    );
}

fn target(text: &str) -> TargetScan {
    TargetScan {
        name: "boolean".to_owned(),
        files: vec![SourceFile {
            path: "src/lib.rs".to_owned(),
            segments: Vec::new(),
            cfg: Vec::new(),
            parsed: syn::parse_file(text).unwrap(),
            text: text.to_owned(),
            rustdoc: true,
        }],
        library: true,
    }
}

#[test]
fn boolean_cli_fixture_preserves_exact_function_and_doctest_members() {
    let text = include_str!("fixtures/booleanws/src/lib.rs");
    let target = target(text);
    let doctest = syn_canon::canonicalize(
        syn::parse_str(
            "fn main() { let input = true; let value = if input { true } else { false }; assert_eq!(value, true); }",
        )
        .unwrap(),
    );
    let run = |threshold| {
        Echoes::default()
            .functions()
            .fn_min_tokens(threshold)
            .run_targets("", std::slice::from_ref(&target), &|_, _| None)
    };
    let size = run(0)
        .groups
        .iter()
        .find(|group| group.kind == echoes::Kind::Function)
        .map_or(0, |group| group.tokens);
    for threshold in [0, size] {
        let report = run(threshold);
        let function_groups: Vec<_> = report
            .groups
            .iter()
            .filter(|group| group.kind == echoes::Kind::Function)
            .collect();
        let function_members = members(function_groups.iter().copied());
        assert_eq!(
            function_members,
            [
                vec!["boolean::Owner::first", "boolean::Owner::same"],
                vec!["boolean::left::first", "boolean::left::same"],
                vec!["boolean::right::first", "boolean::right::same"],
            ]
        );
        assert!(function_groups.iter().all(|group| group.tokens == size));
        assert_public_sites(&function_groups);
        let doctest_groups: Vec<_> = report
            .groups
            .iter()
            .filter(|group| group.kind == echoes::Kind::Doctest)
            .collect();
        assert_eq!(doctest_groups.len(), 1);
        let sites = &doctest_groups[0].sites;
        assert_eq!(sites.len(), 2);
        assert!(sites.iter().all(|site| site.item == "boolean"
            && site.info.is_empty()
            && site.file == "src/lib.rs"));
        assert_ne!(sites[0].line, sites[1].line);
        assert_eq!(doctest_groups[0].tokens, doctest.leaf_tokens());
        let grouped: Vec<&str> = report
            .groups
            .iter()
            .flat_map(|group| group.sites.iter())
            .map(|site| site.item.as_str())
            .collect();
        assert!(!grouped.iter().any(|item| item.contains("different")));
        assert_eq!(grouped.iter().filter(|item| **item == "boolean").count(), 2);
    }
    assert!(
        !run(size + 1)
            .groups
            .iter()
            .any(|group| group.kind == echoes::Kind::Function)
    );
}

/// A balanced tree of `leaves` distinct parameters `a0`, `a1`, … joined by `op`, each written
/// with `prefix`, so no operand repeats and the tree keeps its node count.
fn logical_tree(out: &mut String, next: &mut usize, leaves: usize, op: &str, prefix: &str) {
    if leaves == 1 {
        out.push_str(prefix);
        out.push('a');
        out.push_str(&next.to_string());
        *next += 1;
    } else {
        out.push('(');
        logical_tree(out, next, leaves / 2, op, prefix);
        out.push_str(op);
        logical_tree(out, next, leaves - leaves / 2, op, prefix);
        out.push(')');
    }
}

#[test]
fn boolean_expansion_node_bound_retains_the_complete_region() {
    for leaves in [5_461, 5_462] {
        let tree = |op: &str, prefix: &str| {
            let mut out = String::new();
            logical_tree(&mut out, &mut 0, leaves, op, prefix);
            out
        };
        let conjunction = tree("&&", "");
        let disjunction = tree("||", "!");
        let params = (0..leaves)
            .map(|i| format!(",a{i}:bool"))
            .collect::<Vec<_>>()
            .concat();
        let source = format!("fn f(b:bool{params})->(bool,bool){{(!!b,!({conjunction}))}}");
        let normalized = format!("fn f(b:bool{params})->(bool,bool){{(b,{disjunction})}}");
        let original = form(&source);
        if leaves == 5_461 {
            assert_eq!(original, form(&normalized));
        } else {
            assert_ne!(original, form(&normalized));
            let partial = format!("fn f(b:bool{params})->(bool,bool){{(b,!({conjunction}))}}");
            assert_ne!(original, form(&partial));
            assert_eq!(original, form(&source));
        }
    }
}