1use std::collections::BTreeMap;
7use std::ffi::OsString;
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12use crate::commands::outline::symbol_to_entry;
13use crate::parser::{
14 detect_language, extract_symbols_from_tree, parse_source_with_cached_parser, LangId,
15};
16use crate::symbols::Symbol;
17
18pub const RANGE_SYMBOL_PACKET_DISCLAIMER: &str =
20 "Derived from the diff; proves what changed, not that it works.";
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24pub struct SymbolDiffEntry {
25 pub name: String,
26 pub kind: String,
27 pub signature_line: Option<String>,
28 pub container_path: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct FileSymbolDiff {
35 pub symbols_unavailable: bool,
36 pub old_line_count: usize,
37 pub new_line_count: usize,
38 pub line_count_delta: i64,
39 pub added: Vec<SymbolDiffEntry>,
40 pub removed: Vec<SymbolDiffEntry>,
41 pub modified: Vec<SymbolDiffEntry>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub enum FileChangeKind {
48 Added,
49 Removed,
50 Modified,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct RangeFileSymbolDiff {
56 pub path: String,
58 pub change: FileChangeKind,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub renamed_from: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub renamed_to: Option<String>,
65 #[serde(flatten)]
66 pub diff: FileSymbolDiff,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct RangeSymbolPacket {
72 pub base_sha: String,
73 pub tip_sha: String,
74 pub files: Vec<RangeFileSymbolDiff>,
75 pub disclaimer: &'static str,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
79struct SymbolIdentity {
80 container_path: String,
81 name: String,
82 kind: String,
83}
84
85#[derive(Debug, Clone)]
86struct ParsedSymbol {
87 entry: SymbolDiffEntry,
88 body: Option<Vec<u8>>,
89}
90
91#[derive(Debug)]
92enum GitFileChange {
93 Added(Vec<u8>),
94 Removed(Vec<u8>),
95 Modified(Vec<u8>),
96 Renamed { old: Vec<u8>, new: Vec<u8> },
97 Copied { old: Vec<u8>, new: Vec<u8> },
98}
99
100pub(crate) fn symbol_diff_file(
106 lang: LangId,
107 path: &Path,
108 old: Option<&[u8]>,
109 new: Option<&[u8]>,
110) -> FileSymbolDiff {
111 let old_line_count = line_count(old.unwrap_or_default());
112 let new_line_count = line_count(new.unwrap_or_default());
113
114 if !supports_symbol_diff(lang) {
115 return unavailable_file_diff(old_line_count, new_line_count);
116 }
117
118 let old_by_identity = match old {
119 Some(source) => match parse_symbols(path, source, lang) {
120 Some(symbols) => symbols,
121 None => return unavailable_file_diff(old_line_count, new_line_count),
122 },
123 None => BTreeMap::new(),
124 };
125 let new_by_identity = match new {
126 Some(source) => match parse_symbols(path, source, lang) {
127 Some(symbols) => symbols,
128 None => return unavailable_file_diff(old_line_count, new_line_count),
129 },
130 None => BTreeMap::new(),
131 };
132
133 let mut old_by_identity = old_by_identity;
134 let mut added = Vec::new();
135 let mut modified = Vec::new();
136
137 for (identity, new_symbol) in new_by_identity {
138 match old_by_identity.remove(&identity) {
139 None => added.push(new_symbol.entry),
140 Some(old_symbol) if symbol_changed(&old_symbol, &new_symbol) => {
141 modified.push(new_symbol.entry)
142 }
143 Some(_) => {}
144 }
145 }
146
147 let removed = old_by_identity
148 .into_values()
149 .map(|symbol| symbol.entry)
150 .collect();
151
152 FileSymbolDiff {
153 symbols_unavailable: false,
154 old_line_count,
155 new_line_count,
156 line_count_delta: line_count_delta(old_line_count, new_line_count),
157 added,
158 removed,
159 modified,
160 }
161}
162
163pub fn symbol_diff_range(repo_root: &Path, base_sha: &str, tip_sha: &str) -> RangeSymbolPacket {
169 let mut files = BTreeMap::new();
170
171 if let Some(changes) = git_name_status(repo_root, base_sha, tip_sha) {
172 for change in changes {
173 match change {
174 GitFileChange::Added(path) => {
175 insert_file_entry(
176 &mut files,
177 repo_root,
178 &path,
179 None,
180 read_git_blob(repo_root, tip_sha, &path),
181 FileChangeKind::Added,
182 None,
183 None,
184 );
185 }
186 GitFileChange::Removed(path) => {
187 insert_file_entry(
188 &mut files,
189 repo_root,
190 &path,
191 read_git_blob(repo_root, base_sha, &path),
192 None,
193 FileChangeKind::Removed,
194 None,
195 None,
196 );
197 }
198 GitFileChange::Modified(path) => {
199 insert_file_entry(
200 &mut files,
201 repo_root,
202 &path,
203 read_git_blob(repo_root, base_sha, &path),
204 read_git_blob(repo_root, tip_sha, &path),
205 FileChangeKind::Modified,
206 None,
207 None,
208 );
209 }
210 GitFileChange::Renamed { old, new } => {
211 let old_display = git_path_display(&old);
212 let new_display = git_path_display(&new);
213 insert_file_entry(
214 &mut files,
215 repo_root,
216 &old,
217 read_git_blob(repo_root, base_sha, &old),
218 None,
219 FileChangeKind::Removed,
220 None,
221 Some(new_display),
222 );
223 insert_file_entry(
224 &mut files,
225 repo_root,
226 &new,
227 None,
228 read_git_blob(repo_root, tip_sha, &new),
229 FileChangeKind::Added,
230 Some(old_display),
231 None,
232 );
233 }
234 GitFileChange::Copied { old, new } => {
235 insert_file_entry(
236 &mut files,
237 repo_root,
238 &new,
239 None,
240 read_git_blob(repo_root, tip_sha, &new),
241 FileChangeKind::Added,
242 Some(git_path_display(&old)),
243 None,
244 );
245 }
246 }
247 }
248 }
249
250 RangeSymbolPacket {
251 base_sha: base_sha.to_string(),
252 tip_sha: tip_sha.to_string(),
253 files: files.into_values().collect(),
254 disclaimer: RANGE_SYMBOL_PACKET_DISCLAIMER,
255 }
256}
257
258fn supports_symbol_diff(lang: LangId) -> bool {
259 !matches!(lang, LangId::Json | LangId::Yaml)
260}
261
262fn unavailable_file_diff(old_line_count: usize, new_line_count: usize) -> FileSymbolDiff {
263 FileSymbolDiff {
264 symbols_unavailable: true,
265 old_line_count,
266 new_line_count,
267 line_count_delta: line_count_delta(old_line_count, new_line_count),
268 added: Vec::new(),
269 removed: Vec::new(),
270 modified: Vec::new(),
271 }
272}
273
274fn parse_symbols(
275 path: &Path,
276 source: &[u8],
277 lang: LangId,
278) -> Option<BTreeMap<SymbolIdentity, ParsedSymbol>> {
279 let source = std::str::from_utf8(source).ok()?;
280 let tree = parse_source_with_cached_parser(path, source, lang).ok()?;
281 let symbols = extract_symbols_from_tree(source, &tree, lang).ok()?;
282
283 let mut parsed = BTreeMap::new();
284 for symbol in symbols {
285 let (identity, parsed_symbol) = parsed_symbol(source, symbol);
286 parsed.entry(identity).or_insert(parsed_symbol);
289 }
290 Some(parsed)
291}
292
293fn parsed_symbol(source: &str, symbol: Symbol) -> (SymbolIdentity, ParsedSymbol) {
294 let outline_entry = symbol_to_entry(&symbol);
295 let container_path = symbol.scope_chain.join(".");
296 let identity = SymbolIdentity {
297 container_path: container_path.clone(),
298 name: outline_entry.name.clone(),
299 kind: outline_entry.kind.clone(),
300 };
301 let entry = SymbolDiffEntry {
302 name: outline_entry.name,
303 kind: outline_entry.kind,
304 signature_line: outline_entry.signature,
305 container_path,
306 };
307 let body = source_bytes_for_symbol(source, &symbol);
308 (identity, ParsedSymbol { entry, body })
309}
310
311fn source_bytes_for_symbol(source: &str, symbol: &Symbol) -> Option<Vec<u8>> {
312 let start = byte_offset_at(source, symbol.range.start_line, symbol.range.start_col)?;
313 let end = byte_offset_at(source, symbol.range.end_line, symbol.range.end_col)?;
314 (start <= end).then(|| source.as_bytes()[start..end].to_vec())
315}
316
317fn byte_offset_at(source: &str, line: u32, column: u32) -> Option<usize> {
318 let bytes = source.as_bytes();
319 let mut line_start = 0;
320 for _ in 0..line {
321 let newline = bytes[line_start..].iter().position(|byte| *byte == b'\n')?;
322 line_start = line_start.checked_add(newline + 1)?;
323 }
324 let offset = line_start.checked_add(column as usize)?;
325 (offset <= bytes.len()).then_some(offset)
326}
327
328fn symbol_changed(old: &ParsedSymbol, new: &ParsedSymbol) -> bool {
329 old.body != new.body || old.entry.signature_line != new.entry.signature_line
330}
331
332fn line_count(source: &[u8]) -> usize {
333 if source.is_empty() {
334 return 0;
335 }
336 source.iter().filter(|byte| **byte == b'\n').count() + usize::from(!source.ends_with(b"\n"))
337}
338
339fn line_count_delta(old_line_count: usize, new_line_count: usize) -> i64 {
340 let delta = new_line_count as i128 - old_line_count as i128;
341 delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64
342}
343
344fn insert_file_entry(
345 files: &mut BTreeMap<Vec<u8>, RangeFileSymbolDiff>,
346 _repo_root: &Path,
347 path_bytes: &[u8],
348 old: Option<Vec<u8>>,
349 new: Option<Vec<u8>>,
350 change: FileChangeKind,
351 renamed_from: Option<String>,
352 renamed_to: Option<String>,
353) {
354 let path = path_from_git_bytes(path_bytes);
355 let diff = match detect_language(&path) {
356 Some(lang) => symbol_diff_file(lang, &path, old.as_deref(), new.as_deref()),
357 None => unavailable_file_diff(
358 line_count(old.as_deref().unwrap_or_default()),
359 line_count(new.as_deref().unwrap_or_default()),
360 ),
361 };
362 files.insert(
363 path_bytes.to_vec(),
364 RangeFileSymbolDiff {
365 path: git_path_display(path_bytes),
366 change,
367 renamed_from,
368 renamed_to,
369 diff,
370 },
371 );
372}
373
374fn git_name_status(repo_root: &Path, base_sha: &str, tip_sha: &str) -> Option<Vec<GitFileChange>> {
375 let range = format!("{base_sha}..{tip_sha}");
376 let output = crate::effective_path::new_command("git")
377 .arg("-C")
378 .arg(repo_root)
379 .args([
380 "-c",
381 "core.quotepath=false",
382 "diff",
383 "--name-status",
384 "-z",
385 "-M",
386 "--no-ext-diff",
387 &range,
388 ])
389 .output()
390 .ok()?;
391 output
392 .status
393 .success()
394 .then(|| parse_name_status(&output.stdout))
395}
396
397fn parse_name_status(output: &[u8]) -> Vec<GitFileChange> {
398 let fields = output
399 .split(|byte| *byte == 0)
400 .filter(|field| !field.is_empty())
401 .collect::<Vec<_>>();
402 let mut changes = Vec::new();
403 let mut index = 0;
404
405 while let Some(status) = fields.get(index) {
406 index += 1;
407 let kind = status.first().copied();
408 match kind {
409 Some(b'R') => {
410 let (Some(old), Some(new)) = (fields.get(index), fields.get(index + 1)) else {
411 break;
412 };
413 changes.push(GitFileChange::Renamed {
414 old: (*old).to_vec(),
415 new: (*new).to_vec(),
416 });
417 index += 2;
418 }
419 Some(b'C') => {
420 let (Some(old), Some(new)) = (fields.get(index), fields.get(index + 1)) else {
421 break;
422 };
423 changes.push(GitFileChange::Copied {
424 old: (*old).to_vec(),
425 new: (*new).to_vec(),
426 });
427 index += 2;
428 }
429 Some(kind) => {
430 let Some(path) = fields.get(index) else {
431 break;
432 };
433 let change = match kind {
434 b'A' => GitFileChange::Added((*path).to_vec()),
435 b'D' => GitFileChange::Removed((*path).to_vec()),
436 _ => GitFileChange::Modified((*path).to_vec()),
437 };
438 changes.push(change);
439 index += 1;
440 }
441 None => break,
442 }
443 }
444
445 changes
446}
447
448fn read_git_blob(repo_root: &Path, sha: &str, path_bytes: &[u8]) -> Option<Vec<u8>> {
449 let object = git_object_spec(sha, path_bytes);
450 let output = crate::effective_path::new_command("git")
451 .arg("-C")
452 .arg(repo_root)
453 .args(["-c", "core.quotepath=false", "show", "--no-textconv"])
454 .arg(object)
455 .output()
456 .ok()?;
457 output.status.success().then_some(output.stdout)
458}
459
460fn git_path_display(path_bytes: &[u8]) -> String {
461 String::from_utf8_lossy(path_bytes).replace('\\', "/")
462}
463
464fn path_from_git_bytes(bytes: &[u8]) -> PathBuf {
465 #[cfg(unix)]
466 {
467 use std::os::unix::ffi::OsStringExt;
468
469 PathBuf::from(OsString::from_vec(bytes.to_vec()))
470 }
471
472 #[cfg(not(unix))]
473 {
474 PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
475 }
476}
477
478fn git_object_spec(sha: &str, path_bytes: &[u8]) -> OsString {
479 #[cfg(unix)]
480 {
481 use std::os::unix::ffi::OsStringExt;
482
483 let mut bytes = sha.as_bytes().to_vec();
484 bytes.push(b':');
485 bytes.extend_from_slice(path_bytes);
486 OsString::from_vec(bytes)
487 }
488
489 #[cfg(not(unix))]
490 {
491 OsString::from(format!("{sha}:{}", String::from_utf8_lossy(path_bytes)))
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use std::fs;
498 use std::path::Path;
499 use std::process::Command;
500 use std::time::{Duration, SystemTime};
501
502 use filetime::FileTime;
503 use tempfile::TempDir;
504
505 use super::*;
506
507 const RUST_BASE: &str = r#"
508pub fn unchanged() -> i32 { 0 }
509pub fn body_changed() -> i32 { 1 }
510pub fn signature_changed(value: i32) -> i32 { value }
511pub fn removed() -> i32 { 4 }
512"#;
513 const RUST_TIP: &str = r#"
514pub fn unchanged() -> i32 { 0 }
515pub fn body_changed() -> i32 { 2 }
516pub fn signature_changed(value: i64) -> i64 { value }
517pub fn added() -> i32 { 3 }
518"#;
519 const TS_BASE: &str = r#"
520export function unchanged(): number { return 0; }
521export function bodyChanged(): number { return 1; }
522export function signatureChanged(value: number): number { return value; }
523export function removed(): number { return 4; }
524"#;
525 const TS_TIP: &str = r#"
526export function unchanged(): number { return 0; }
527export function bodyChanged(): number { return 2; }
528export function signatureChanged(value: string): string { return value; }
529export function added(): number { return 3; }
530"#;
531
532 #[test]
533 fn rust_classifies_added_removed_and_both_kinds_of_modification() {
534 let diff = symbol_diff_file(
535 LangId::Rust,
536 Path::new("multi.rs"),
537 Some(RUST_BASE.as_bytes()),
538 Some(RUST_TIP.as_bytes()),
539 );
540
541 assert!(!diff.symbols_unavailable);
542 assert_eq!(entry_names(&diff.added), ["added"]);
543 assert_eq!(entry_names(&diff.removed), ["removed"]);
544 assert_eq!(
545 entry_names(&diff.modified),
546 ["body_changed", "signature_changed"]
547 );
548 assert_eq!(
549 diff.modified[1].signature_line.as_deref(),
550 Some("pub fn signature_changed(value: i64) -> i64 { value }")
551 );
552 }
553
554 #[test]
555 fn typescript_classifies_added_removed_and_both_kinds_of_modification() {
556 let diff = symbol_diff_file(
557 LangId::TypeScript,
558 Path::new("multi.ts"),
559 Some(TS_BASE.as_bytes()),
560 Some(TS_TIP.as_bytes()),
561 );
562
563 assert!(!diff.symbols_unavailable);
564 assert_eq!(entry_names(&diff.added), ["added"]);
565 assert_eq!(entry_names(&diff.removed), ["removed"]);
566 assert_eq!(
567 entry_names(&diff.modified),
568 ["bodyChanged", "signatureChanged"]
569 );
570 assert_eq!(
571 diff.modified[1].signature_line.as_deref(),
572 Some("function signatureChanged(value: string): string { return value; }")
573 );
574 }
575
576 #[test]
577 fn json_returns_an_honest_line_count_only_fallback() {
578 let diff = symbol_diff_file(
579 LangId::Json,
580 Path::new("data.json"),
581 Some(b"{\"one\": 1}\n"),
582 Some(b"{\"one\": 1}\n{\"two\": 2}\n"),
583 );
584
585 assert!(diff.symbols_unavailable);
586 assert_eq!(diff.old_line_count, 1);
587 assert_eq!(diff.new_line_count, 2);
588 assert_eq!(diff.line_count_delta, 1);
589 assert!(diff.added.is_empty());
590 assert!(diff.removed.is_empty());
591 assert!(diff.modified.is_empty());
592 }
593
594 #[test]
595 fn range_is_stable_across_renders_and_worktree_mtime_changes() {
596 let _git_env = crate::test_env::hermetic_git_env_guard();
597 let (temp, base, tip) = committed_range("src/lib.rs", RUST_BASE, RUST_TIP);
598 let first = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
599 .expect("serialize first packet");
600 let second = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
601 .expect("serialize second packet");
602 assert_eq!(first, second);
603
604 let source = temp.path().join("src/lib.rs");
605 filetime::set_file_mtime(
606 &source,
607 FileTime::from_system_time(SystemTime::now() + Duration::from_secs(60)),
608 )
609 .expect("change worktree mtime");
610 let third = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
611 .expect("serialize third packet");
612 assert_eq!(first, third);
613 }
614
615 #[test]
616 fn range_uses_nul_delimited_unicode_paths_and_expands_renames() {
617 let _git_env = crate::test_env::hermetic_git_env_guard();
618 let temp = init_git_fixture();
619 let old_name = "src/before ü name.rs";
620 let new_name = "src/after ü name.rs";
621 write_file(temp.path(), old_name, "pub fn renamed() {}\n");
622 let base = commit_all(temp.path(), "base");
623 fs::rename(temp.path().join(old_name), temp.path().join(new_name)).expect("rename fixture");
624 let tip = commit_all(temp.path(), "rename");
625
626 let packet = symbol_diff_range(temp.path(), &base, &tip);
627 assert_eq!(packet.files.len(), 2);
628 assert_eq!(packet.files[0].path, new_name);
629 assert_eq!(packet.files[0].change, FileChangeKind::Added);
630 assert_eq!(packet.files[0].renamed_from.as_deref(), Some(old_name));
631 assert_eq!(entry_names(&packet.files[0].diff.added), ["renamed"]);
632 assert_eq!(packet.files[1].path, old_name);
633 assert_eq!(packet.files[1].change, FileChangeKind::Removed);
634 assert_eq!(packet.files[1].renamed_to.as_deref(), Some(new_name));
635 assert_eq!(entry_names(&packet.files[1].diff.removed), ["renamed"]);
636 }
637
638 #[test]
639 fn range_packet_disclaimer_is_byte_exact_and_bare_repositories_work() {
640 let _git_env = crate::test_env::hermetic_git_env_guard();
641 let (temp, base, tip) = committed_range("src/lib.rs", RUST_BASE, RUST_TIP);
642 let bare = tempfile::tempdir().expect("create bare parent");
643 let bare_repo = bare.path().join("range.git");
644 let mut command = Command::new("git");
645 let status = crate::test_env::apply_hermetic_git_env(command.current_dir(temp.path()))
646 .args(["clone", "--bare", "."])
647 .arg(&bare_repo)
648 .status()
649 .expect("clone bare fixture");
650 assert!(status.success(), "clone bare fixture failed");
651 let packet = symbol_diff_range(&bare_repo, &base, &tip);
652 let bytes = serde_json::to_vec(&packet).expect("serialize packet");
653
654 assert_eq!(packet.disclaimer, RANGE_SYMBOL_PACKET_DISCLAIMER);
655 assert!(bytes
656 .windows(RANGE_SYMBOL_PACKET_DISCLAIMER.len())
657 .any(|window| window == RANGE_SYMBOL_PACKET_DISCLAIMER.as_bytes()));
658 assert!(!String::from_utf8(bytes)
659 .expect("packet is UTF-8 JSON")
660 .contains(&temp.path().display().to_string()));
661 }
662
663 fn entry_names(entries: &[SymbolDiffEntry]) -> Vec<&str> {
664 entries.iter().map(|entry| entry.name.as_str()).collect()
665 }
666
667 fn committed_range(
668 path: &str,
669 base_source: &str,
670 tip_source: &str,
671 ) -> (TempDir, String, String) {
672 let temp = init_git_fixture();
673 write_file(temp.path(), path, base_source);
674 let base = commit_all(temp.path(), "base");
675 write_file(temp.path(), path, tip_source);
676 let tip = commit_all(temp.path(), "tip");
677 (temp, base, tip)
678 }
679
680 fn init_git_fixture() -> TempDir {
681 let temp = tempfile::tempdir().expect("create git fixture");
682 run_git(temp.path(), ["init"].as_slice());
683 run_git(
684 temp.path(),
685 ["config", "user.email", "test@example.com"].as_slice(),
686 );
687 run_git(temp.path(), ["config", "user.name", "AFT Test"].as_slice());
688 temp
689 }
690
691 fn write_file(root: &Path, relative_path: &str, content: &str) {
692 let path = root.join(relative_path);
693 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
694 fs::write(path, content).expect("write fixture");
695 }
696
697 fn commit_all(root: &Path, message: &str) -> String {
698 run_git(root, ["add", "."].as_slice());
699 run_git(root, ["commit", "-m", message].as_slice());
700 run_git(root, ["rev-parse", "HEAD"].as_slice())
701 }
702
703 fn run_git(root: &Path, args: &[&str]) -> String {
704 let mut command = Command::new("git");
705 let output = crate::test_env::apply_hermetic_git_env(command.current_dir(root))
706 .args(args)
707 .output()
708 .expect("run git");
709 assert!(
710 output.status.success(),
711 "git {:?} failed: {}",
712 args,
713 String::from_utf8_lossy(&output.stderr)
714 );
715 String::from_utf8(output.stdout)
716 .expect("git stdout is UTF-8")
717 .trim()
718 .to_string()
719 }
720}