Skip to main content

_diffctx/
render.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use once_cell::sync::Lazy;
5use regex::Regex;
6use rustc_hash::{FxHashMap, FxHashSet};
7use serde::Serialize;
8
9use crate::config::render::RENDER;
10use crate::types::{Fragment, FragmentId, FragmentKind};
11
12/// Orientation header for the diff-context output: tells the reader *what*
13/// changed before they read the fragments. Empty for working-tree / no-commit
14/// diffs.
15#[derive(Default)]
16pub struct ChangeSummary {
17    pub commit_message: Option<String>,
18    pub changed_files: Vec<String>,
19    pub deleted_files: Vec<String>,
20    pub renamed_files: Vec<(String, String)>,
21}
22
23fn serialize_renames<S>(renames: &[(String, String)], serializer: S) -> Result<S::Ok, S::Error>
24where
25    S: serde::Serializer,
26{
27    use serde::ser::SerializeSeq;
28    let mut seq = serializer.serialize_seq(Some(renames.len()))?;
29    for (from, to) in renames {
30        let mut m = std::collections::BTreeMap::new();
31        m.insert("from", from);
32        m.insert("to", to);
33        seq.serialize_element(&m)?;
34    }
35    seq.end()
36}
37
38#[derive(Serialize)]
39pub struct DiffContextOutput {
40    pub name: String,
41    #[serde(rename = "type")]
42    pub output_type: String,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub commit_message: Option<String>,
45    #[serde(skip_serializing_if = "Vec::is_empty")]
46    pub changed_files: Vec<String>,
47    #[serde(skip_serializing_if = "Vec::is_empty")]
48    pub deleted_files: Vec<String>,
49    #[serde(
50        skip_serializing_if = "Vec::is_empty",
51        serialize_with = "serialize_renames"
52    )]
53    pub renamed_files: Vec<(String, String)>,
54    pub fragment_count: usize,
55    pub fragments: Vec<FragmentEntry>,
56    #[serde(skip)]
57    pub latency: Option<LatencyBreakdown>,
58}
59
60pub struct LatencyBreakdown {
61    pub parse_changed_ms: f64,
62    pub universe_walk_ms: f64,
63    pub discovery_ms: f64,
64    pub parse_discovered_ms: f64,
65    pub tokenization_ms: f64,
66    /// Typed dependency graph construction: edge builders + dedup + hub
67    /// suppression + per-source cap. Carved out of `scoring_ms` (which
68    /// used to absorb it) so the cost distribution is truthful. Zero for
69    /// BM25 mode (no graph built).
70    pub graph_build_ms: f64,
71    /// Combined graph build + scoring + selection time. Kept for
72    /// backward compatibility with the existing checkpoint schema; the
73    /// split values below are the new diagnostic signal.
74    pub scoring_selection_ms: f64,
75    pub total_ms: f64,
76    /// Heavy-phase rank computation only (PPR/EGO/BM25 + relevance
77    /// filtering). Graph construction is reported in `graph_build_ms`;
78    /// the selection stage is excluded.
79    pub scoring_ms: f64,
80    /// Selection stage only (lazy greedy / Boltzmann + post-passes).
81    pub selection_ms: f64,
82    /// Size of the candidate fragment universe handed to the scoring
83    /// strategy (after fragment generation + signature variants but
84    /// before per-strategy filtering). Surfaces blowup on large repos —
85    /// pathological scoring time is correlated with this number, not
86    /// with `fragment_count` (which is the *output* size after
87    /// selection).
88    pub candidate_count: usize,
89    /// Edge count of the typed dependency graph used by PPR/EGO. Zero
90    /// for BM25 mode (no graph built).
91    pub edge_count: usize,
92    /// Number of greedy iterations actually executed (selected non-core
93    /// fragments). Bounded by `selected.len() - core.len()`. Pairs with
94    /// `selection_ms` to spot lazy-heap blowup vs. genuine large output.
95    pub greedy_iters: usize,
96    /// Edge count after merge + hub suppression, before per-source cap.
97    pub edges_before_cap: usize,
98    /// Edges discarded by the per-source top-K cap.
99    pub edges_dropped_by_cap: usize,
100    /// Source nodes whose outgoing edge list was truncated by the cap.
101    pub nodes_capped: usize,
102    /// The K value applied for the per-source cap.
103    pub max_out_edges_per_node: usize,
104    /// PPR push iteration was truncated by `max_pushes_cap` before
105    /// convergence. When true, `rel_scores` are biased toward seeds
106    /// and absolute file_recall on this instance should be flagged
107    /// in post-analysis. Always false for non-PPR scoring modes.
108    pub ppr_truncated: bool,
109    pub ppr_forward_pushes: usize,
110    pub ppr_backward_pushes: usize,
111    /// Additive stopping certificate: upper bound
112    /// (`tau * peak_density * remaining_budget`) on utility foregone by
113    /// adaptive stopping. 0 when the greedy loop ended for another
114    /// reason (budget exhausted, no candidates, singleton override).
115    pub stopping_certificate: f64,
116    /// Lifetime peak physical memory of the process, sampled in-process
117    /// at the end of the run. 0 when the platform query fails.
118    pub peak_rss_bytes: u64,
119    /// Per-category (raw, first-seen deduped) edge emission counts from
120    /// pass 1 of the two-pass edge build, sorted by category name. Names
121    /// the builder category behind near-dense emission blowups (#116).
122    /// Empty for BM25 mode (no graph built).
123    pub edge_emissions_by_category: Vec<(&'static str, u64, u64)>,
124}
125
126#[derive(Serialize, Clone)]
127pub struct FragmentEntry {
128    pub path: String,
129    pub lines: String,
130    /// `Some("changed")` for fragments overlapping the diff hunks; omitted
131    /// (treated as supporting context) otherwise. This is the single signal a
132    /// reader needs to tell the change apart from its context.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub role: Option<String>,
135    pub kind: String,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub symbol: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub content: Option<Arc<str>>,
140}
141
142struct SymbolPatterns {
143    function: Vec<Regex>,
144    class: Vec<Regex>,
145    r#struct: Vec<Regex>,
146    interface: Vec<Regex>,
147    r#enum: Vec<Regex>,
148    r#impl: Vec<Regex>,
149    r#type: Vec<Regex>,
150    module: Vec<Regex>,
151    section: Vec<Regex>,
152}
153
154static SYMBOL_PATTERNS: Lazy<SymbolPatterns> = Lazy::new(|| {
155    SymbolPatterns {
156    function: vec![
157        Regex::new(r"(?m)^\s*(?:async\s+)?def\s+(\w+)\s*\(").unwrap(),
158        Regex::new(r"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[\(<]").unwrap(),
159        Regex::new(r"(?m)^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|\w)\s*=>").unwrap(),
160        Regex::new(r"(?m)^func\s+(?:\([^)]+\)\s+)?(\w+)\s*[\(\[]").unwrap(),
161        Regex::new(r"(?m)^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[\(<]").unwrap(),
162        Regex::new(r"(?m)^\s*(?:(?:public|private|protected|static)\s+)*\w[\w<>\[\],]*\s+(\w+)\s*\(").unwrap(),
163    ],
164    class: vec![
165        Regex::new(r"(?m)^\s*class\s+(\w+)\s*[:\({\s]").unwrap(),
166        Regex::new(r"(?m)^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)").unwrap(),
167    ],
168    r#struct: vec![
169        Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)").unwrap(),
170        Regex::new(r"(?m)^\s*type\s+(\w+)\s+struct\s*\{").unwrap(),
171    ],
172    interface: vec![
173        Regex::new(r"(?m)^\s*(?:export\s+)?interface\s+(\w+)").unwrap(),
174        Regex::new(r"(?m)^\s*type\s+(\w+)\s+interface\s*\{").unwrap(),
175        Regex::new(r"(?m)^\s*(?:pub\s+)?trait\s+(\w+)").unwrap(),
176    ],
177    r#enum: vec![
178        Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)").unwrap(),
179        Regex::new(r"(?m)^\s*class\s+(\w+)\s*\(.*Enum\)").unwrap(),
180    ],
181    r#impl: vec![
182        Regex::new(r"(?m)^\s*impl(?:<[^>]+>)?\s+(\w+)").unwrap(),
183    ],
184    r#type: vec![
185        Regex::new(r"(?m)^\s*(?:export\s+)?type\s+(\w+)").unwrap(),
186        Regex::new(r"(?m)^\s*type\s+(\w+)\s").unwrap(),
187    ],
188    module: vec![
189        Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)").unwrap(),
190        Regex::new(r"(?m)^\s*package\s+(\w+)").unwrap(),
191    ],
192    section: vec![
193        Regex::new(r"(?m)^#{1,6}\s+(\S[^\n]*)$").unwrap(),
194    ],
195}
196});
197
198fn extract_symbol(frag: &Fragment) -> Option<String> {
199    let patterns = match frag.kind {
200        FragmentKind::Function | FragmentKind::FunctionSignature => &SYMBOL_PATTERNS.function,
201        FragmentKind::Class | FragmentKind::ClassSignature => &SYMBOL_PATTERNS.class,
202        FragmentKind::Struct | FragmentKind::StructSignature => &SYMBOL_PATTERNS.r#struct,
203        FragmentKind::Interface | FragmentKind::InterfaceSignature => &SYMBOL_PATTERNS.interface,
204        FragmentKind::Enum | FragmentKind::EnumSignature => &SYMBOL_PATTERNS.r#enum,
205        FragmentKind::Impl => &SYMBOL_PATTERNS.r#impl,
206        FragmentKind::Type => &SYMBOL_PATTERNS.r#type,
207        FragmentKind::Module => &SYMBOL_PATTERNS.module,
208        FragmentKind::Section => &SYMBOL_PATTERNS.section,
209        _ => return None,
210    };
211
212    for pattern in patterns {
213        if let Some(caps) = pattern.captures(&frag.content) {
214            if let Some(m) = caps.get(1) {
215                let result = m.as_str().trim();
216                return Some(if frag.kind == FragmentKind::Section {
217                    result
218                        .chars()
219                        .take(RENDER.section_symbol_max_chars)
220                        .collect()
221                } else {
222                    result.to_string()
223                });
224            }
225        }
226    }
227    None
228}
229
230fn get_relative_path(frag: &Fragment, repo_root: &Path) -> String {
231    let frag_path = Path::new(frag.path());
232    if !frag_path.is_absolute() {
233        return frag_path.to_string_lossy().replace('\\', "/");
234    }
235    frag_path
236        .strip_prefix(repo_root)
237        .unwrap_or(frag_path)
238        .to_string_lossy()
239        .replace('\\', "/")
240}
241
242fn create_fragment_entry(frag: &Fragment, path_str: &str) -> FragmentEntry {
243    let symbol = frag.symbol_name.clone().or_else(|| extract_symbol(frag));
244    let content = if frag.content.is_empty() {
245        None
246    } else {
247        Some(Arc::clone(&frag.content))
248    };
249
250    FragmentEntry {
251        path: path_str.to_string(),
252        lines: format!("{}-{}", frag.start_line(), frag.end_line()),
253        role: None,
254        kind: frag.kind.as_str().to_string(),
255        symbol,
256        content,
257    }
258}
259
260/// Collapse a file's fragments (sorted by start line, ties by descending end
261/// line) into the rendered entries. Two behaviors:
262/// - a same-role fragment fully contained in the running range (`next.end <=
263///   end`) is dropped: its content is already covered by the enclosing
264///   fragment (e.g. a symbol-level "function" extraction and a hunk-level
265///   "chunk" both covering the same edited lines), so keeping it is pure
266///   duplication, not additional information.
267/// - a same-role fragment that is line-contiguous with the running range
268///   (`next.start == end + 1`) is merged into it.
269/// Both are lossless on line coverage and remove the per-fragment scaffolding
270/// tax that dominates output on one-line/near-duplicate snippets.
271fn merge_file_fragments(
272    rel_path: &str,
273    frags: &[&Fragment],
274    core_ids: &FxHashSet<FragmentId>,
275) -> Vec<(bool, u32, FragmentEntry)> {
276    let mut out: Vec<(bool, u32, FragmentEntry)> = Vec::new();
277    let mut i = 0;
278    while i < frags.len() {
279        let first = frags[i];
280        let role_changed = core_ids.contains(&first.id);
281        let mut end = first.end_line();
282        let mut parts: Vec<&str> = vec![first.content.trim_end_matches('\n')];
283        let mut j = i + 1;
284        while j < frags.len() {
285            let next = frags[j];
286            if core_ids.contains(&next.id) != role_changed {
287                break;
288            }
289            if next.end_line() <= end {
290                // Fully contained in the range covered so far - redundant.
291                j += 1;
292            } else if next.start_line() == end + 1 {
293                parts.push(next.content.trim_end_matches('\n'));
294                end = next.end_line();
295                j += 1;
296            } else {
297                break;
298            }
299        }
300
301        let mut entry = create_fragment_entry(first, rel_path);
302        if j > i + 1 {
303            entry.lines = format!("{}-{}", first.start_line(), end);
304            let merged = parts.join("\n");
305            entry.content = if merged.is_empty() {
306                None
307            } else {
308                Some(Arc::from(merged.as_str()))
309            };
310        }
311        entry.role = role_changed.then(|| "changed".to_string());
312        out.push((role_changed, first.start_line(), entry));
313        i = j;
314    }
315    out
316}
317
318pub fn build_diff_context_output(
319    repo_root: &Path,
320    selected: &[Fragment],
321    no_content: bool,
322    core_ids: &FxHashSet<FragmentId>,
323    rel_scores: &FxHashMap<FragmentId, f64>,
324    change: ChangeSummary,
325) -> DiffContextOutput {
326    let mut by_path: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
327    for frag in selected {
328        by_path
329            .entry(get_relative_path(frag, repo_root))
330            .or_default()
331            .push(frag);
332    }
333
334    // Changed code first (the answer to "what changed"), then supporting
335    // context ordered by descending per-file relevance so the reader's primacy
336    // attention lands on the most relevant material, not on alphabetical noise.
337    let mut changed: Vec<(String, u32, FragmentEntry)> = Vec::new();
338    let mut context: Vec<(f64, String, u32, FragmentEntry)> = Vec::new();
339    for (rel_path, frags) in &by_path {
340        let mut sorted: Vec<&Fragment> = frags.clone();
341        // Tie-break by descending end line so, among same-start fragments, the
342        // widest range sorts first and containment-absorption below (which scans
343        // forward from the first entry of a run) sees the enclosing range before
344        // any of its nested sub-fragments.
345        sorted.sort_by_key(|f| (f.start_line(), std::cmp::Reverse(f.end_line())));
346        let file_rel = sorted
347            .iter()
348            .map(|f| rel_scores.get(&f.id).copied().unwrap_or(0.0))
349            .fold(0.0_f64, f64::max);
350        for (role_changed, start, mut entry) in merge_file_fragments(rel_path, &sorted, core_ids) {
351            if no_content {
352                entry.content = None;
353            }
354            if role_changed {
355                changed.push((rel_path.clone(), start, entry));
356            } else {
357                context.push((file_rel, rel_path.clone(), start, entry));
358            }
359        }
360    }
361
362    changed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
363    context.sort_by(|a, b| {
364        b.0.partial_cmp(&a.0)
365            .unwrap_or(std::cmp::Ordering::Equal)
366            .then(a.1.cmp(&b.1))
367            .then(a.2.cmp(&b.2))
368    });
369
370    let mut fragments_out: Vec<FragmentEntry> = Vec::with_capacity(changed.len() + context.len());
371    fragments_out.extend(changed.into_iter().map(|(_, _, e)| e));
372    fragments_out.extend(context.into_iter().map(|(_, _, _, e)| e));
373
374    let resolved = repo_root
375        .canonicalize()
376        .unwrap_or_else(|_| repo_root.to_path_buf());
377    let name = resolved
378        .file_name()
379        .map(|n| n.to_string_lossy().to_string())
380        .unwrap_or_else(|| resolved.to_string_lossy().to_string());
381
382    DiffContextOutput {
383        name,
384        output_type: "diff_context".to_string(),
385        commit_message: change.commit_message,
386        changed_files: change.changed_files,
387        deleted_files: change.deleted_files,
388        renamed_files: change.renamed_files,
389        fragment_count: fragments_out.len(),
390        fragments: fragments_out,
391        latency: None,
392    }
393}