heddle-semantic 0.28.2

An AI-native version control system
Documentation
use std::collections::BTreeSet;

use super::*;
use crate::{SemanticParseCache, parser::Language};

pub(super) fn run(
    path: &str,
    base: Option<&str>,
    source: Option<&str>,
    symbols: &[String],
    out: &mut Comparison,
) {
    let problem = if !path.ends_with(".rs") {
        Some(Limitation::Language)
    } else if base.is_none() || source.is_none() {
        Some(Limitation::MissingSource)
    } else if [base, source]
        .iter()
        .flatten()
        .any(|s| s.len() > MAX_SOURCE_BYTES)
    {
        Some(Limitation::Budget)
    } else {
        None
    };
    if let Some(problem) = problem {
        omit(out, problem);
        return;
    }
    let cache = SemanticParseCache::shared();
    let (Some(base), Some(source)) = (
        base.and_then(|s| cache.parse(s, Language::Rust)),
        source.and_then(|s| cache.parse(s, Language::Rust)),
    ) else {
        omit(out, Limitation::ParseError);
        return;
    };
    let (mut old, old_exhausted) = extract::extract(path, &base, Side::Base, symbols);
    let (mut new, new_exhausted) = extract::extract(path, &source, Side::Source, symbols);
    // Never pair a target from a truncated inventory: a duplicate may have
    // been omitted, so even the retained prefix is not safe correspondence.
    if !old_exhausted || !new_exhausted {
        omit(out, Limitation::Budget);
        return;
    }
    let keys: BTreeSet<_> = old.keys().chain(new.keys()).cloned().collect();
    if keys.is_empty() {
        out.analyzed.push(Scope {
            symbol_address: String::new(),
            target: String::new(),
            support: Support::Supported,
            limitations: vec![],
            sources: vec![],
        });
    }
    for (symbol, target) in keys {
        let old = old
            .remove(&(symbol.clone(), target.clone()))
            .unwrap_or_default();
        let new = new
            .remove(&(symbol.clone(), target.clone()))
            .unwrap_or_default();
        let mut scope = Scope {
            symbol_address: symbol.clone(),
            target,
            support: Support::Supported,
            limitations: vec![],
            sources: old
                .iter()
                .chain(&new)
                .flat_map(|c| c.assignments.iter().map(|a| a.source.clone()))
                .collect(),
        };
        // Full group equality suppresses comment/whitespace-only edits even
        // where duplicate targets prevent confident cross-revision matching.
        let identical = old.len() == new.len()
            && (base.source() == source.source()
                || old.iter().zip(&new).all(|(a, b)| equivalent(a, b)));
        if identical {
            out.analyzed.push(scope);
            continue;
        }
        let conditional = old
            .iter()
            .chain(&new)
            .any(|c| c.expressions.iter().any(|e| e.conditional.is_some()));
        if !conditional {
            scope.support = Support::Unsupported;
            scope.limitations.push(Limitation::Syntax);
            out.omitted.push(scope);
            continue;
        }
        let ambiguous = old.len() > 1 || new.len() > 1;
        let mut change = Change {
            id: String::new(),
            symbol_address: symbol,
            support: Support::Supported,
            limitations: vec![],
            operations: vec![],
            assignments: vec![],
            expressions: vec![],
            bindings: vec![],
            correspondences: vec![],
        };
        let old_ids = old
            .iter()
            .flat_map(|c| c.assignments.iter().map(|a| a.value_id.clone()))
            .collect::<Vec<_>>();
        let new_ids = new
            .iter()
            .flat_map(|c| c.assignments.iter().map(|a| a.value_id.clone()))
            .collect::<Vec<_>>();
        for c in old.into_iter().chain(new) {
            change.operations.extend(c.operations);
            change.assignments.extend(c.assignments);
            change.expressions.extend(c.expressions);
            change.bindings.extend(c.bindings);
            for limitation in c.limitations {
                if !change.limitations.contains(&limitation) {
                    change.limitations.push(limitation);
                }
            }
        }
        change.id = extract::hash(&[
            path.as_bytes(),
            base.content_hash().as_bytes(),
            source.content_hash().as_bytes(),
            change.symbol_address.as_bytes(),
            scope.target.as_bytes(),
            &COMPARISON_VERSION.to_le_bytes(),
            binding_key(extraction_key(ContentHash::compute_typed(
                "blob",
                base.source().as_bytes(),
            )))
            .as_bytes(),
            binding_key(extraction_key(ContentHash::compute_typed(
                "blob",
                source.source().as_bytes(),
            )))
            .as_bytes(),
        ])
        .to_hex();
        if ambiguous {
            change.limitations.push(Limitation::AmbiguousMatch);
            push(
                &mut change,
                old_ids,
                new_ids,
                CorrespondenceKind::Ambiguous,
                vec![],
            );
        } else if change.limitations.iter().any(|l| {
            matches!(
                l,
                Limitation::Syntax
                    | Limitation::MutableBinding
                    | Limitation::MissingElse
                    | Limitation::Budget
            )
        }) {
            push(
                &mut change,
                old_ids,
                new_ids,
                CorrespondenceKind::Unmatched,
                vec![],
            );
        } else {
            correspond(&mut change, old_ids.first(), new_ids.first());
        }
        if !change.limitations.is_empty() {
            change.support = Support::Partial;
        }
        scope.support = change.support;
        scope.limitations = change.limitations.clone();
        out.analyzed.push(scope);
        out.changes.push(change);
    }
}

fn omit(out: &mut Comparison, reason: Limitation) {
    out.exhausted = !matches!(reason, Limitation::Budget | Limitation::MissingSource);
    out.omitted.push(Scope {
        symbol_address: String::new(),
        target: String::new(),
        support: if matches!(reason, Limitation::MissingSource | Limitation::Budget) {
            Support::Unavailable
        } else {
            Support::Unsupported
        },
        limitations: vec![reason],
        sources: vec![],
    });
}

fn expression<'a>(change: &'a Change, id: &str) -> Option<&'a Expression> {
    change.expressions.iter().find(|e| e.id == id)
}

fn resolved_hash(change: &Change, id: &str, depth: usize) -> Option<ContentHash> {
    if depth > 32 {
        return None;
    }
    let value = expression(change, id)?;
    if let Some(binding) = change.bindings.iter().find(|b| b.occurrence_id == id) {
        if binding.support == Support::Supported {
            return resolved_hash(change, binding.value_id.as_deref()?, depth + 1);
        }
        return (binding.limitations == [Limitation::UnresolvedBinding])
            .then_some(value.normalized_hash);
    }
    let mut children = change
        .bindings
        .iter()
        .filter_map(|b| {
            let occurrence = expression(change, &b.occurrence_id)?;
            (occurrence.source.side == value.source.side
                && occurrence.source.span.start >= value.source.span.start
                && occurrence.source.span.end <= value.source.span.end)
                .then_some((occurrence.source.span.start, b.occurrence_id.as_str()))
        })
        .collect::<Vec<_>>();
    children.sort();
    if children.is_empty() {
        return Some(value.normalized_hash);
    }
    let mut hashes = vec![value.normalized_hash];
    for (_, child) in children {
        hashes.push(resolved_hash(change, child, depth + 1)?);
    }
    Some(extract::hash(
        &hashes
            .iter()
            .map(|h| h.as_bytes().as_slice())
            .collect::<Vec<_>>(),
    ))
}

fn equivalent(a: &Change, b: &Change) -> bool {
    let (Some(a_value), Some(b_value)) = (
        a.assignments
            .first()
            .and_then(|v| expression(a, &v.value_id)),
        b.assignments
            .first()
            .and_then(|v| expression(b, &v.value_id)),
    ) else {
        return false;
    };
    if a_value.normalized_hash != b_value.normalized_hash {
        return false;
    }
    match (&a_value.conditional, &b_value.conditional) {
        (Some(ac), Some(bc)) => {
            let (ah, bh) = (
                predicate_hash(a, &ac.predicate_id),
                predicate_hash(b, &bc.predicate_id),
            );
            ah.is_some() && ah == bh
        }
        (None, None) => true,
        _ => false,
    }
}

fn predicate_hash(change: &Change, id: &str) -> Option<ContentHash> {
    resolved_hash(change, id, 0)
}

fn push(
    change: &mut Change,
    base_ids: Vec<String>,
    source_ids: Vec<String>,
    kind: CorrespondenceKind,
    reasons: Vec<MatchReason>,
) {
    let id = extract::hash(&[
        change.id.as_bytes(),
        b"correspondence",
        &(change.correspondences.len() as u64).to_le_bytes(),
    ])
    .to_hex();
    change.correspondences.push(Correspondence {
        id,
        base_ids,
        source_ids,
        kind,
        reasons,
    });
}

fn pair(change: &mut Change, old: &str, new: &str, predicate: bool, branch: bool) {
    let a = expression(change, old).map(|e| e.normalized_hash);
    let b = expression(change, new).map(|e| e.normalized_hash);
    let (a, b) = if predicate {
        (predicate_hash(change, old), predicate_hash(change, new))
    } else {
        (a, b)
    };
    let mut reasons = vec![MatchReason::SameTargetInMatchedSymbol];
    if branch {
        reasons.push(MatchReason::SameBranchLabel);
    }
    let kind = match (a, b) {
        (Some(a), Some(b)) if a == b => {
            reasons.push(if predicate {
                MatchReason::ResolvedBinding
            } else {
                MatchReason::ExactNormalizedExpression
            });
            CorrespondenceKind::Retained
        }
        (Some(_), Some(_)) => CorrespondenceKind::Replaced,
        _ => CorrespondenceKind::Unmatched,
    };
    push(change, vec![old.into()], vec![new.into()], kind, reasons);
}

fn correspond(change: &mut Change, old: Option<&String>, new: Option<&String>) {
    match (old, new) {
        (Some(old), Some(new)) => {
            let a = expression(change, old).and_then(|e| e.conditional.clone());
            let b = expression(change, new).and_then(|e| e.conditional.clone());
            match (a, b) {
                (None, Some(b)) => {
                    for branch in b.branches {
                        pair(change, old, &branch.result_id, false, false);
                    }
                }
                (Some(a), None) => {
                    for branch in a.branches {
                        pair(change, &branch.result_id, new, false, false);
                    }
                }
                (Some(a), Some(b)) => {
                    pair(change, &a.predicate_id, &b.predicate_id, true, false);
                    for (a, b) in a.branches.iter().zip(&b.branches) {
                        pair(change, &a.result_id, &b.result_id, false, true);
                    }
                }
                (None, None) => pair(change, old, new, false, false),
            }
        }
        (None, Some(new)) => push(
            change,
            vec![],
            vec![new.clone()],
            CorrespondenceKind::Added,
            vec![],
        ),
        (Some(old), None) => push(
            change,
            vec![old.clone()],
            vec![],
            CorrespondenceKind::Removed,
            vec![],
        ),
        (None, None) => {}
    }
}