//! Utilities for parsing unified diff hunks and extracting specific line content. use ai::agent::action::CommentSide;use num_traits::SaturatingSub;use warp_editor::render::model::LineCount; use crate::code::editor::line::EditorLineLocation;use crate::code_review::comments::LineDiffContent;use crate::code_review::diff_state::DiffLineType;use crate::util::git::parse_unified_diff_header; #[derive(Debug)]pub(crate) enum DiffHunkParseError { EmptyHunk, InvalidHeader(anyhow::Error), UnexpectedHunkHeader { line_index: usize }, LineNotFound { target_line: usize },} impl std::fmt::Display for DiffHunkParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DiffHunkParseError::EmptyHunk => write!(f, "Empty diff hunk"), DiffHunkParseError::InvalidHeader(err) => write!(f, "Invalid header: {err}"), DiffHunkParseError::UnexpectedHunkHeader { line_index } => { write!(f, "Unexpected hunk header at line index {line_index}") } DiffHunkParseError::LineNotFound { target_line } => { write!(f, "Target line {target_line} not found in hunk") } } }} impl From<anyhow::Error> for DiffHunkParseError { fn from(err: anyhow::Error) -> Self { DiffHunkParseError::InvalidHeader(err) }} /// Build the result tuple from a diff line's type and content.fn build_line_result( line: &str, line_type: &DiffLineType, line_index_in_hunk: usize, new_file_line: LineCount,) -> Result<(EditorLineLocation, LineDiffContent), DiffHunkParseError> { // EditorLineLocation expects 0-based line numbers, but diff hunks are 1-based. let line_num = new_file_line.saturating_sub(&LineCount::from(1)); let editor_line_location = match line_type { DiffLineType::Context | DiffLineType::Add => EditorLineLocation::Current { line_number: line_num, line_range: line_num..line_num, }, DiffLineType::Delete => EditorLineLocation::Removed { line_number: line_num, line_range: line_num..line_num, index: 0, }, DiffLineType::HunkHeader => { return Err(DiffHunkParseError::UnexpectedHunkHeader { line_index: line_index_in_hunk, }); } }; let lines_added = usize::from(matches!(line_type, DiffLineType::Add)); let lines_removed = usize::from(matches!(line_type, DiffLineType::Delete)); let line_diff_content = LineDiffContent { content: line.to_string(), lines_added: LineCount::from(lines_added), lines_removed: LineCount::from(lines_removed), }; Ok((editor_line_location, line_diff_content))} fn get_diff_line_from_diff_hunk( diff_hunk: &str, target_line_number: usize, side: CommentSide,) -> Result<(EditorLineLocation, LineDiffContent), DiffHunkParseError> { let parsed_lines: Vec<&str> = diff_hunk.lines().collect(); let diff_hunk_header = parsed_lines .first() .ok_or(DiffHunkParseError::EmptyHunk) .and_then(|line| parse_unified_diff_header(line).map_err(Into::into))?; let mut index_in_file = match side { CommentSide::Left => diff_hunk_header.old_start_line, CommentSide::Right => diff_hunk_header.new_start_line, }; for (index_in_hunk, line) in parsed_lines.iter().enumerate().skip(1) { let line_type = match line.chars().next().unwrap_or_default() { '@' => { return Err(DiffHunkParseError::UnexpectedHunkHeader { line_index: index_in_hunk, }); } '+' => DiffLineType::Add, '-' => DiffLineType::Delete, _ => DiffLineType::Context, }; match side { CommentSide::Left => { if matches!(line_type, DiffLineType::Delete | DiffLineType::Context) { if index_in_file == target_line_number { return build_line_result( line, &line_type, index_in_hunk, LineCount::from(index_in_file), ); } index_in_file += 1; } } CommentSide::Right => { if matches!(line_type, DiffLineType::Add | DiffLineType::Context) { if index_in_file == target_line_number { return build_line_result( line, &line_type, index_in_hunk, LineCount::from(index_in_file), ); } index_in_file += 1; } } } } Err(DiffHunkParseError::LineNotFound { target_line: target_line_number, })} /// Given a diff hunk and a target start and end line within it, return the EditorLineLocation and LineDiffContent for the target.////// # Arguments/// * `diff_hunk` - A unified diff hunk string starting with `@@ -old_file_hunk_start,old_hunk_line_count +new_file_hunk_start,new_hunk_line_count @@`/// * `target_new_file_line` - The 1-indexed line number in the new file that the comment was attached to./// * `side` - Optionally specify which side of the diff the target line is in. If None, tries Right first, then Left.////// # Returns/// * the `EditorLineLocation` representing the comment, where the line_range represents the location in the new file/// * the `LineDiffContent` from the parsed diff hunk.pub(crate) fn parse_diff_hunk( diff_hunk: &str, target_line_number: usize, side: Option<CommentSide>,) -> Result<(EditorLineLocation, LineDiffContent), DiffHunkParseError> { match side { Some(side) => get_diff_line_from_diff_hunk(diff_hunk, target_line_number, side), None => { // Try Right first (new file), then fall back to Left (old file) let mut diff_line_result = get_diff_line_from_diff_hunk(diff_hunk, target_line_number, CommentSide::Right); if matches!( diff_line_result, Err(DiffHunkParseError::LineNotFound { .. }) ) { diff_line_result = get_diff_line_from_diff_hunk(diff_hunk, target_line_number, CommentSide::Left); } diff_line_result } }} #[cfg(test)]#[path = "diff_hunk_parser_tests.rs"]mod tests;
//! Utilities for parsing unified diff hunks and extracting specific line content.
use ai::agent::action::CommentSide;
use num_traits::SaturatingSub;
use warp_editor::render::model::LineCount;
use crate::code::editor::line::EditorLineLocation;
use crate::code_review::comments::LineDiffContent;
use crate::code_review::diff_state::DiffLineType;
use crate::util::git::parse_unified_diff_header;
#[derive(Debug)]
pub(crate) enum DiffHunkParseError {
EmptyHunk,
InvalidHeader(anyhow::Error),
UnexpectedHunkHeader { line_index: usize },
LineNotFound { target_line: usize },
}
impl std::fmt::Display for DiffHunkParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiffHunkParseError::EmptyHunk => write!(f, "Empty diff hunk"),
DiffHunkParseError::InvalidHeader(err) => write!(f, "Invalid header: {err}"),
DiffHunkParseError::UnexpectedHunkHeader { line_index } => {
write!(f, "Unexpected hunk header at line index {line_index}")
}
DiffHunkParseError::LineNotFound { target_line } => {
write!(f, "Target line {target_line} not found in hunk")
}
}
}
}
impl From<anyhow::Error> for DiffHunkParseError {
fn from(err: anyhow::Error) -> Self {
DiffHunkParseError::InvalidHeader(err)
}
}
/// Build the result tuple from a diff line's type and content.
fn build_line_result(
line: &str,
line_type: &DiffLineType,
line_index_in_hunk: usize,
new_file_line: LineCount,
) -> Result<(EditorLineLocation, LineDiffContent), DiffHunkParseError> {
// EditorLineLocation expects 0-based line numbers, but diff hunks are 1-based.
let line_num = new_file_line.saturating_sub(&LineCount::from(1));
let editor_line_location = match line_type {
DiffLineType::Context | DiffLineType::Add => EditorLineLocation::Current {
line_number: line_num,
line_range: line_num..line_num,
},
DiffLineType::Delete => EditorLineLocation::Removed {
line_number: line_num,
line_range: line_num..line_num,
index: 0,
},
DiffLineType::HunkHeader => {
return Err(DiffHunkParseError::UnexpectedHunkHeader {
line_index: line_index_in_hunk,
});
}
};
let lines_added = usize::from(matches!(line_type, DiffLineType::Add));
let lines_removed = usize::from(matches!(line_type, DiffLineType::Delete));
let line_diff_content = LineDiffContent {
content: line.to_string(),
lines_added: LineCount::from(lines_added),
lines_removed: LineCount::from(lines_removed),
};
Ok((editor_line_location, line_diff_content))
}
fn get_diff_line_from_diff_hunk(
diff_hunk: &str,
target_line_number: usize,
side: CommentSide,
) -> Result<(EditorLineLocation, LineDiffContent), DiffHunkParseError> {
let parsed_lines: Vec<&str> = diff_hunk.lines().collect();
let diff_hunk_header = parsed_lines
.first()
.ok_or(DiffHunkParseError::EmptyHunk)
.and_then(|line| parse_unified_diff_header(line).map_err(Into::into))?;
let mut index_in_file = match side {
CommentSide::Left => diff_hunk_header.old_start_line,
CommentSide::Right => diff_hunk_header.new_start_line,
};
for (index_in_hunk, line) in parsed_lines.iter().enumerate().skip(1) {
let line_type = match line.chars().next().unwrap_or_default() {
'@' => {
return Err(DiffHunkParseError::UnexpectedHunkHeader {
line_index: index_in_hunk,
});
}
'+' => DiffLineType::Add,
'-' => DiffLineType::Delete,
_ => DiffLineType::Context,
};
match side {
CommentSide::Left => {
if matches!(line_type, DiffLineType::Delete | DiffLineType::Context) {
if index_in_file == target_line_number {
return build_line_result(
line,
&line_type,
index_in_hunk,
LineCount::from(index_in_file),
);
}
index_in_file += 1;
}
}
CommentSide::Right => {
if matches!(line_type, DiffLineType::Add | DiffLineType::Context) {
if index_in_file == target_line_number {
return build_line_result(
line,
&line_type,
index_in_hunk,
LineCount::from(index_in_file),
);
}
index_in_file += 1;
}
}
}
}
Err(DiffHunkParseError::LineNotFound {
target_line: target_line_number,
})
}
/// Given a diff hunk and a target start and end line within it, return the EditorLineLocation and LineDiffContent for the target.
///
/// # Arguments
/// * `diff_hunk` - A unified diff hunk string starting with `@@ -old_file_hunk_start,old_hunk_line_count +new_file_hunk_start,new_hunk_line_count @@`
/// * `target_new_file_line` - The 1-indexed line number in the new file that the comment was attached to.
/// * `side` - Optionally specify which side of the diff the target line is in. If None, tries Right first, then Left.
///
/// # Returns
/// * the `EditorLineLocation` representing the comment, where the line_range represents the location in the new file
/// * the `LineDiffContent` from the parsed diff hunk.
pub(crate) fn parse_diff_hunk(
diff_hunk: &str,
target_line_number: usize,
side: Option<CommentSide>,
) -> Result<(EditorLineLocation, LineDiffContent), DiffHunkParseError> {
match side {
Some(side) => get_diff_line_from_diff_hunk(diff_hunk, target_line_number, side),
None => {
// Try Right first (new file), then fall back to Left (old file)
let mut diff_line_result =
get_diff_line_from_diff_hunk(diff_hunk, target_line_number, CommentSide::Right);
if matches!(
diff_line_result,
Err(DiffHunkParseError::LineNotFound { .. })
) {
diff_line_result =
get_diff_line_from_diff_hunk(diff_hunk, target_line_number, CommentSide::Left);
}
diff_line_result
}
}
}
#[cfg(test)]
#[path = "diff_hunk_parser_tests.rs"]
mod tests;