cli: Add diff parsing

Parse Git's unified diff format into Radicle Surf's types.  Support to
and from the format helps testing and debugging.

Split the UnifiedDiff trait into Decode and Encode traits so we
can offer Decode in cases where no data could be expected..
For example Option<Hunk<_>> and Hunk<_> allowing partial and repeated
decoding on a byte stream.

Avoid decoding `Diff`, `DiffContent::Binary`, `FileDiff`, and `FileHeader`
types.  `Diff` keeps fields and methods private which are necessary for
decoding.
This commit is contained in:
Slack Coder 2023-08-08 09:22:45 -05:00 committed by cloudhead
parent b337e1b1cb
commit cb78f144be
No known key found for this signature in database
3 changed files with 380 additions and 44 deletions

View File

@ -3,10 +3,44 @@ use std::fmt;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use thiserror::Error;
use radicle::git;
use radicle::git::raw::Oid;
use radicle_surf::diff;
use radicle_surf::diff::{Diff, DiffContent, DiffFile, FileDiff, Hunk, Hunks, Line, Modification};
use crate::terminal as term; use crate::terminal as term;
use radicle::git::raw::Oid; #[derive(Debug, Error)]
use radicle_surf::diff::{Diff, DiffContent, DiffFile, FileDiff, Hunk, Modification}; pub enum Error {
/// Attempt to decode from a source with no data left.
#[error("unexpected end of file")]
UnexpectedEof,
#[error(transparent)]
Io(#[from] io::Error),
/// Catchall for syntax error messages.
#[error("{0}")]
Syntax(String),
#[error(transparent)]
ParseInt(#[from] std::num::ParseIntError),
#[error(transparent)]
Utf8(#[from] std::string::FromUtf8Error),
}
impl Error {
pub fn syntax(msg: impl ToString) -> Self {
Self::Syntax(msg.to_string())
}
pub fn is_eof(&self) -> bool {
match self {
Self::UnexpectedEof => true,
Self::Io(e) => e.kind() == io::ErrorKind::UnexpectedEof,
_ => false,
}
}
}
/// The kind of FileDiff Header which can be used to print the FileDiff information which precedes /// The kind of FileDiff Header which can be used to print the FileDiff information which precedes
/// `Hunks`. /// `Hunks`.
@ -68,13 +102,13 @@ impl std::convert::From<&FileDiff> for FileHeader {
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
pub struct HunkHeader { pub struct HunkHeader {
/// Line the hunk started in the old file. /// Line the hunk started in the old file.
pub old_line_no: usize, pub old_line_no: u32,
/// Number of removed and context lines. /// Number of removed and context lines.
pub old_size: usize, pub old_size: u32,
/// Line the hunk started in the new file. /// Line the hunk started in the new file.
pub new_line_no: usize, pub new_line_no: u32,
/// Number of added and context lines. /// Number of added and context lines.
pub new_size: usize, pub new_size: u32,
/// Trailing text for the Hunk Header. /// Trailing text for the Hunk Header.
/// ///
/// From Git's documentation "Hunk headers mention the name of the function to which the hunk /// From Git's documentation "Hunk headers mention the name of the function to which the hunk
@ -84,49 +118,121 @@ pub struct HunkHeader {
pub text: Vec<u8>, pub text: Vec<u8>,
} }
/// A Trait for converting a value to its UnifiedDiff format. impl HunkHeader {
pub trait UnifiedDiff: Sized { pub fn old_line_range(&self) -> std::ops::Range<u32> {
fn encode(&self, w: &mut Writer) -> io::Result<()>; let start: u32 = self.old_line_no;
let end: u32 = self.old_line_no + self.old_size;
start..end
}
fn to_unified_string(&self) -> String { pub fn new_line_range(&self) -> std::ops::Range<u32> {
let mut buf = Vec::new(); let start: u32 = self.new_line_no;
let end: u32 = self.new_line_no + self.new_size;
{ start..end
let mut w = Writer::new(&mut buf);
w.encode(self).unwrap();
}
String::from_utf8(buf).unwrap()
} }
} }
impl UnifiedDiff for Diff { /// Diff-related types that can be decoded from the unified diff format.
fn encode(&self, w: &mut Writer) -> io::Result<()> { pub trait Decode: Sized {
/// Decode, and fail if we reach the end of the stream.
fn decode(r: &mut impl io::BufRead) -> Result<Self, Error>;
/// Decode, and return a `None` if we reached the end of the stream.
fn try_decode(r: &mut impl io::BufRead) -> Result<Option<Self>, Error> {
match Self::decode(r) {
Ok(v) => Ok(Some(v)),
Err(Error::UnexpectedEof) => Ok(None),
Err(e) => Err(e),
}
}
/// Decode from a string input.
fn parse(s: &str) -> Result<Self, Error> {
let mut r = io::BufReader::new(s.as_bytes());
Self::decode(&mut r)
}
}
/// Diff-related types that can be encoded intro the unified diff format.
pub trait Encode: Sized {
/// Encode type into diff writer.
fn encode(&self, w: &mut Writer) -> Result<(), Error>;
/// Encode into unified diff string.
fn to_unified_string(&self) -> Result<String, Error> {
let mut buf = Vec::new();
let mut w = Writer::new(&mut buf);
w.encode(self)?;
drop(w);
String::from_utf8(buf).map_err(Error::from)
}
}
impl Decode for Diff {
/// Decode from git's unified diff format, consuming the entire input.
fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
let mut s = String::new();
r.read_to_string(&mut s)?;
let d = git::raw::Diff::from_buffer(s.as_ref())
.map_err(|e| Error::syntax(format!("decoding unified diff: {}", e)))?;
let d = Diff::try_from(d)
.map_err(|e| Error::syntax(format!("decoding unified diff: {}", e)))?;
Ok(d)
}
}
impl Encode for Diff {
fn encode(&self, w: &mut Writer) -> Result<(), Error> {
for fdiff in self.files() { for fdiff in self.files() {
fdiff.encode(w)?; fdiff.encode(w)?;
} }
Ok(()) Ok(())
} }
} }
impl UnifiedDiff for DiffContent { impl Decode for DiffContent {
fn encode(&self, w: &mut Writer) -> io::Result<()> { fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
let mut hunks = Vec::default();
while let Some(h) = Hunk::<_>::try_decode(r)? {
hunks.push(h);
}
if hunks.is_empty() {
Ok(DiffContent::Empty)
} else {
// TODO: Handle case for binary.
Ok(DiffContent::Plain {
hunks: Hunks::from(hunks),
// TODO: Properly handle EndOfLine field
eof: diff::EofNewLine::NoneMissing,
})
}
}
}
impl Encode for DiffContent {
fn encode(&self, w: &mut Writer) -> Result<(), Error> {
match self { match self {
DiffContent::Plain { hunks, .. } => { DiffContent::Plain { hunks, .. } => {
for h in hunks.iter() { for h in hunks.iter() {
h.encode(w)?; h.encode(w)?;
} }
Ok(())
} }
DiffContent::Empty => Ok(()), DiffContent::Empty => {}
DiffContent::Binary => unimplemented!(), DiffContent::Binary => todo!("DiffContent::Binary encoding not implemented"),
} }
Ok(())
} }
} }
impl UnifiedDiff for FileDiff { impl Encode for FileDiff {
fn encode(&self, w: &mut Writer) -> io::Result<()> { fn encode(&self, w: &mut Writer) -> Result<(), Error> {
w.encode(&FileHeader::from(self))?; w.encode(&FileHeader::from(self))?;
match self { match self {
FileDiff::Modified(f) => { FileDiff::Modified(f) => {
@ -151,8 +257,8 @@ impl UnifiedDiff for FileDiff {
} }
} }
impl UnifiedDiff for FileHeader { impl Encode for FileHeader {
fn encode(&self, w: &mut Writer) -> io::Result<()> { fn encode(&self, w: &mut Writer) -> Result<(), Error> {
match self { match self {
FileHeader::Modified { path, old, new } => { FileHeader::Modified { path, old, new } => {
w.meta(format!( w.meta(format!(
@ -234,8 +340,43 @@ impl UnifiedDiff for FileHeader {
} }
} }
impl UnifiedDiff for HunkHeader { impl Decode for HunkHeader {
fn encode(&self, w: &mut Writer) -> io::Result<()> { fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
let mut line = String::default();
if r.read_line(&mut line)? == 0 {
return Err(Error::UnexpectedEof);
};
let mut header = HunkHeader::default();
let s = line
.strip_prefix("@@ -")
.ok_or(Error::syntax("missing '@@ -'"))?;
let (old, s) = s
.split_once(" +")
.ok_or(Error::syntax("missing new line information"))?;
let (line_no, size) = old.split_once(',').unwrap_or((old, "1"));
header.old_line_no = line_no.parse()?;
header.old_size = size.parse()?;
let (new, s) = s
.split_once(" @@")
.ok_or(Error::syntax("closing '@@' is missing"))?;
let (line_no, size) = new.split_once(',').unwrap_or((new, "1"));
header.new_line_no = line_no.parse()?;
header.new_size = size.parse()?;
let s = s.strip_prefix(' ').unwrap_or(s);
header.text = s.as_bytes().to_vec();
Ok(header)
}
}
impl Encode for HunkHeader {
fn encode(&self, w: &mut Writer) -> Result<(), Error> {
let old = if self.old_size == 1 { let old = if self.old_size == 1 {
format!("{}", self.old_line_no) format!("{}", self.old_line_no)
} else { } else {
@ -251,16 +392,80 @@ impl UnifiedDiff for HunkHeader {
} else { } else {
format!(" {}", String::from_utf8_lossy(&self.text)) format!(" {}", String::from_utf8_lossy(&self.text))
}; };
w.meta(format!("@@ -{old} +{new} @@{text}"))?;
w.meta(format!("@@ -{old} +{new} @@{text}")) Ok(())
} }
} }
impl UnifiedDiff for Hunk<Modification> { impl Decode for Hunk<Modification> {
fn encode(&self, w: &mut Writer) -> io::Result<()> { fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
let header = HunkHeader::decode(r)?;
let mut lines = Vec::new();
let mut new_line: u32 = 0;
let mut old_line: u32 = 0;
while old_line < header.old_size || new_line < header.new_size {
if old_line > header.old_size {
return Err(Error::syntax(format!(
"expected '{}' old lines",
header.old_size
)));
} else if new_line > header.new_size {
return Err(Error::syntax(format!(
"expected '{0}' new lines",
header.new_size
)));
}
let Some(line) = Modification::try_decode(r)? else {
return Err(Error::syntax(format!(
"expected '{}' old lines and '{}' new lines, but found '{}' and '{}'",
header.old_size, header.new_size, old_line, new_line,
)));
};
let line = match line {
Modification::Addition(v) => {
let l = Modification::addition(v.line, header.new_line_no + new_line);
new_line += 1;
l
}
Modification::Deletion(v) => {
let l = Modification::deletion(v.line, header.old_line_no + old_line);
old_line += 1;
l
}
Modification::Context { line, .. } => {
let l = Modification::Context {
line,
line_no_old: header.old_line_no + old_line,
line_no_new: header.new_line_no + new_line,
};
new_line += 1;
old_line += 1;
l
}
};
lines.push(line);
}
Ok(Hunk {
header: Line::from(header.to_unified_string()?),
lines,
old: header.old_line_range(),
new: header.new_line_range(),
})
}
}
impl Encode for Hunk<Modification> {
fn encode(&self, w: &mut Writer) -> Result<(), Error> {
// TODO: Remove trailing newlines accurately. // TODO: Remove trailing newlines accurately.
// trim_end() will destroy diff information if the diff has a trailing whitespace on // `trim_end()` will destroy diff information if the diff has a trailing whitespace on
// purpose. // purpose.
w.magenta(self.header.from_utf8_lossy().trim_end())?; w.magenta(self.header.from_utf8_lossy().trim_end())?;
for l in &self.lines { for l in &self.lines {
l.encode(w)?; l.encode(w)?;
@ -270,22 +475,52 @@ impl UnifiedDiff for Hunk<Modification> {
} }
} }
impl UnifiedDiff for Modification { impl Decode for Modification {
fn encode(&self, w: &mut Writer) -> io::Result<()> { fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
let mut line = String::new();
if r.read_line(&mut line)? == 0 {
return Err(Error::UnexpectedEof);
};
let mut chars = line.chars();
let l = match chars.next() {
Some('+') => Modification::addition(chars.as_str().to_string(), 0),
Some('-') => Modification::deletion(chars.as_str().to_string(), 0),
Some(' ') => Modification::Context {
line: chars.as_str().to_string().into(),
line_no_old: 0,
line_no_new: 0,
},
Some(c) => {
return Err(Error::syntax(format!(
"indicator character expected, but got '{c}'",
)))
}
None => return Err(Error::UnexpectedEof),
};
Ok(l)
}
}
impl Encode for Modification {
fn encode(&self, w: &mut Writer) -> Result<(), Error> {
match self { match self {
Modification::Deletion(radicle_surf::diff::Deletion { line, .. }) => { Modification::Deletion(radicle_surf::diff::Deletion { line, .. }) => {
let s = format!("-{}", String::from_utf8_lossy(line.as_bytes()).trim_end()); let s = format!("-{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
w.write(s, term::Style::new(term::Color::Red)) w.write(s, term::Style::new(term::Color::Red))?;
} }
Modification::Addition(radicle_surf::diff::Addition { line, .. }) => { Modification::Addition(radicle_surf::diff::Addition { line, .. }) => {
let s = format!("+{}", String::from_utf8_lossy(line.as_bytes()).trim_end()); let s = format!("+{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
w.write(s, term::Style::new(term::Color::Green)) w.write(s, term::Style::new(term::Color::Green))?;
} }
Modification::Context { line, .. } => { Modification::Context { line, .. } => {
let s = format!(" {}", String::from_utf8_lossy(line.as_bytes()).trim_end()); let s = format!(" {}", String::from_utf8_lossy(line.as_bytes()).trim_end());
w.write(s, term::Style::default().dim()) w.write(s, term::Style::default().dim())?;
} }
} }
Ok(())
} }
} }
@ -303,8 +538,9 @@ impl<'a> Writer<'a> {
} }
} }
pub fn encode(&mut self, arg: &impl UnifiedDiff) -> io::Result<()> { pub fn encode<T: Encode>(&mut self, arg: &T) -> Result<(), Error> {
arg.encode(self) arg.encode(self)?;
Ok(())
} }
pub fn styled(mut self, value: bool) -> Self { pub fn styled(mut self, value: bool) -> Self {
@ -328,3 +564,39 @@ impl<'a> Writer<'a> {
self.write(s, term::Style::new(term::Color::Magenta)) self.write(s, term::Style::new(term::Color::Magenta))
} }
} }
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_diff_encode_decode_diff() {
let diff_a = diff::Diff::parse(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/data/diff.diff"
)))
.unwrap();
assert_eq!(
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/diff.diff")),
diff_a.to_unified_string().unwrap()
);
}
#[test]
fn test_diff_content_encode_decode_content() {
let diff_content = diff::DiffContent::parse(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/data/diff_body.diff"
)))
.unwrap();
assert_eq!(
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/data/diff_body.diff"
)),
diff_content.to_unified_string().unwrap()
);
}
// TODO: Test parsing a real diff from this repository.
}

View File

@ -0,0 +1,34 @@
diff --git a/README.md b/README.md
index 18fb832..ce60f40 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,8 @@ componentwise
reusing
simplest
crag
-offended
omitting
+offended
grinders
cutting
proverb
@@ -21,8 +21,8 @@ alliterative
stays
mahogany
whole
-snowflake
ellipses
+snowflake
yearning
inciting
permeating
@@ -30,3 +30,9 @@ bereaved
eventfully
hears
vacationing
+
+imprisoning
+absinthe
+shack
+exhale
+perfected

View File

@ -0,0 +1,30 @@
@@ -4,8 +4,8 @@ componentwise
reusing
simplest
crag
-offended
omitting
+offended
grinders
cutting
proverb
@@ -21,8 +21,8 @@ alliterative
stays
mahogany
whole
-snowflake
ellipses
+snowflake
yearning
inciting
permeating
@@ -30,3 +30,9 @@ bereaved
eventfully
hears
vacationing
+
+imprisoning
+absinthe
+shack
+exhale
+perfected