diffctx 1.12.1

Selects the minimum code an LLM needs to review a git diff: walks the dependency graph outward from changed lines and stops when extra context stops paying for itself
Documentation
use std::path::{Path, PathBuf};

use once_cell::sync::Lazy;
use regex::Regex;
use rustc_hash::{FxHashMap, FxHashSet};

use crate::config::edge_weights::ANSIBLE_SEMANTIC;
use crate::config::weights::EDGE_WEIGHTS;
use crate::types::Fragment;

use super::super::EdgeDict;
use super::super::base::{
    self, EdgeBuilder, FragmentIndex, add_edge, discover_files_by_refs, link_by_name,
    link_by_path_match,
};

static ANSIBLE_EXTS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
    [".yml", ".yaml", ".j2", ".jinja2", ".jinja"]
        .iter()
        .copied()
        .collect()
});

static INCLUDE_VARS_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r#"(?m)^\s*(?:include_vars|vars_files)\s*:\s*["']?([^\s"']{1,300})["']?"#).unwrap()
});
static INCLUDE_TASKS_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r#"(?m)^\s*(?:include_tasks|import_tasks|include_role|import_role|import_playbook)\s*:\s*["']?([^\s"']{1,300})["']?"#).unwrap()
});
static TEMPLATE_SRC_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r#"(?m)^\s*src\s*:\s*["']?([^\s"']{1,300}\.j(?:2|inja2?))["']?"#).unwrap()
});
static ROLES_LIST_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(?m)^\s*-\s+(?:role:\s*)?([a-zA-Z_][\w.\-]{0,200})\s*$").unwrap());
static VARS_FILES_LIST_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"(?m)^\s*-\s+["']([^"']{1,300}\.ya?ml)["']"#).unwrap());

fn is_ansible_file(path: &Path) -> bool {
    let ext = base::file_ext(path);
    ANSIBLE_EXTS.contains(ext.as_str())
}

fn get_role_name(path: &Path) -> Option<String> {
    let parts: Vec<&str> = path.iter().filter_map(|c| c.to_str()).collect();
    for (i, part) in parts.iter().enumerate() {
        if *part == "roles" && i + 1 < parts.len() {
            return Some(parts[i + 1].to_string());
        }
    }
    None
}

fn ref_to_filename(r: &str) -> String {
    r.trim_end_matches('/')
        .split('/')
        .next_back()
        .unwrap_or(r)
        .to_lowercase()
}

fn extract_refs(content: &str, file_path: &Path) -> FxHashSet<String> {
    let mut refs = FxHashSet::default();
    for m in INCLUDE_VARS_RE.captures_iter(content) {
        refs.insert(m[1].to_string());
    }
    for m in INCLUDE_TASKS_RE.captures_iter(content) {
        refs.insert(m[1].to_string());
    }
    for m in TEMPLATE_SRC_RE.captures_iter(content) {
        refs.insert(m[1].to_string());
    }
    for m in VARS_FILES_LIST_RE.captures_iter(content) {
        refs.insert(m[1].to_string());
    }

    if let Some(role) = get_role_name(file_path) {
        let path_str = file_path.to_string_lossy();
        if path_str.contains("/tasks/") {
            refs.insert(format!("roles/{}/handlers/main.yml", role));
            refs.insert(format!("roles/{}/templates/", role));
            refs.insert(format!("roles/{}/files/", role));
        }
    }
    refs
}

fn extract_role_refs(content: &str) -> FxHashSet<String> {
    let mut roles = FxHashSet::default();
    let mut in_roles = false;
    for line in content.lines() {
        let stripped = line.trim();
        if stripped.starts_with("roles:") {
            in_roles = true;
            continue;
        }
        if !in_roles {
            continue;
        }
        if stripped.starts_with("- ") {
            if let Some(c) = ROLES_LIST_RE.captures(line) {
                roles.insert(c[1].to_string());
            }
        } else if !stripped.is_empty() && !stripped.starts_with('#') && stripped.contains(':') {
            in_roles = false;
        }
    }
    roles
}

pub struct AnsibleEdgeBuilder;

impl EdgeBuilder for AnsibleEdgeBuilder {
    fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict {
        let frags: Vec<&Fragment> = fragments
            .iter()
            .filter(|f| is_ansible_file(Path::new(f.path())))
            .collect();
        if frags.is_empty() {
            return FxHashMap::default();
        }

        let include_w = EDGE_WEIGHTS["ansible_include"].forward;
        let role_w = EDGE_WEIGHTS["ansible_role"].forward;
        let rev = EDGE_WEIGHTS["ansible_include"].reverse_factor;
        let sibling_w = include_w * ANSIBLE_SEMANTIC.sibling_modifier;

        let idx = FragmentIndex::new(fragments, repo_root);
        let mut edges: EdgeDict = FxHashMap::default();

        for af in &frags {
            let path = Path::new(af.path());
            for r in extract_refs(&af.content, path) {
                let filename = ref_to_filename(&r);
                link_by_name(&af.id, &filename, &idx, &mut edges, include_w, rev);
                link_by_path_match(&af.id, &r, &idx, &mut edges, include_w, rev);
            }

            for role in extract_role_refs(&af.content) {
                for subdir in ["tasks", "handlers", "templates"] {
                    let path_hint = format!("roles/{}/{}", role, subdir);
                    link_by_path_match(&af.id, &path_hint, &idx, &mut edges, role_w, rev);
                }
            }
        }

        let mut role_frags: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
        for f in &frags {
            if let Some(role) = get_role_name(Path::new(f.path())) {
                role_frags.entry(role).or_default().push(f);
            }
        }
        for group in role_frags.values() {
            for (i, f1) in group.iter().enumerate() {
                for f2 in &group[i + 1..] {
                    add_edge(&mut edges, &f1.id, &f2.id, sibling_w, rev);
                }
            }
        }

        edges
    }

    fn discover_related_files(
        &self,
        changed: &[PathBuf],
        candidates: &[PathBuf],
        repo_root: Option<&Path>,
        file_cache: Option<&FxHashMap<PathBuf, String>>,
    ) -> Vec<PathBuf> {
        let ansible_changed: Vec<&PathBuf> =
            changed.iter().filter(|p| is_ansible_file(p)).collect();
        if ansible_changed.is_empty() {
            return vec![];
        }

        let mut refs = FxHashSet::default();
        let mut role_names = FxHashSet::default();

        for f in &ansible_changed {
            if let Some(content) = base::read_file_cached(f, file_cache) {
                for r in extract_refs(&content, f) {
                    refs.insert(ref_to_filename(&r));
                    refs.insert(r);
                }
                for role in extract_role_refs(&content) {
                    role_names.insert(role);
                }
            }
            if let Some(role) = get_role_name(f) {
                role_names.insert(role);
            }
        }

        for role in &role_names {
            refs.insert(format!("roles/{}/tasks/main.yml", role));
            refs.insert(format!("roles/{}/handlers/main.yml", role));
            refs.insert(format!("roles/{}/templates/", role));
        }

        discover_files_by_refs(&refs, changed, candidates, repo_root)
    }

    fn category_label(&self) -> Option<&str> {
        Some("semantic")
    }
}