1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
use crate::constants::*;
/// Result of parsing a hunk header: <https://en.wikipedia.org/wiki/Diff#Unified_format>
///
/// Example hunk header: `@@ -1,2 +1,2 @@ Initial commit`
///
/// This would mean "old line numbers are 1-2, and new line numbers are 1-2",
/// making the line counts 2 for both.
#[derive(Debug, PartialEq)]
pub(crate) struct HunkHeader {
/// "@@" with the right number of @ chars, usually two.
ats: String,
/// One-based start lines of one or more old sections + one new section.
/// This vector will always have at least two entries, and mostly it will be
/// exactly two.
starts: Vec<usize>,
/// Number of lines in one or more old sections + one new section. This
/// vector will always have at least two entries, and mostly it will be
/// exactly two.
pub(crate) linecounts: Vec<usize>,
pub title: Option<String>,
}
pub(crate) const HUNK_HEADER: &str = "\x1b[36m"; // Cyan
fn hyperlink(string: &str, url: &Option<url::Url>, line_number: usize) -> String {
if let Some(url) = url {
return format!(
"\x1b]8;;{}#{}\x1b\\{}\x1b]8;;\x1b\\",
url, line_number, string
);
}
return string.to_string();
}
impl HunkHeader {
/// Parse a hunk header from a line of text.
///
/// Returns `None` if the line is not a valid hunk header.
pub fn parse(line: &str) -> Option<Self> {
// Count the number of @ chars at the start of the line, followed by a space
let mut initial_at_count = 0;
for c in line.chars() {
if c == '@' {
initial_at_count += 1;
continue;
}
if c == ' ' {
// We found the end of the @ chars
break;
}
// Expected only @ chars followed by a space, this is not it
return None;
}
if initial_at_count < 2 {
// Expected at least two @ chars, this is not it
return None;
}
let mut parts = line.splitn(3 + initial_at_count, ' ');
let initial_ats = parts.next().unwrap();
let expected_count_parts = initial_at_count;
let mut expected_start_char = '-';
let mut starts = Vec::new();
let mut linecounts = Vec::new();
loop {
// Example: "-1,2", or just "-55"
let counts_part = parts.next()?;
// Parse the old line count
let numbers = counts_part
.trim_start_matches(expected_start_char)
.split(',')
.collect::<Vec<_>>();
if numbers.is_empty() || numbers.len() > 2 {
return None;
}
let start = numbers[0].parse::<usize>().ok()?;
let linecount = if numbers.len() == 2 {
numbers[1].parse::<usize>().ok()?
} else {
1
};
starts.push(start);
linecounts.push(linecount);
if starts.len() == expected_count_parts - 1 {
// We are done with all the `-` parts, let's go for the final `+` part
expected_start_char = '+';
}
if starts.len() == expected_count_parts {
// We are done with all the parts
break;
}
}
if parts.next()? != initial_ats {
// Not a hunk header, it wasn't finalized by @@ at the end
return None;
}
// Example: "Initial commit"
let title = parts.next().map(str::to_string);
Some(HunkHeader {
ats: initial_ats.to_string(),
starts,
linecounts,
title,
})
}
/// Render into an ANSI highlighted string, not ending in a newline.
pub fn render(&self, url: &Option<url::Url>) -> Result<String, String> {
let mut rendered = String::new();
rendered.push_str(HUNK_HEADER);
rendered.push_str(&self.ats);
rendered.push(' ');
for i in 0..self.starts.len() {
if i == self.starts.len() - 1 {
rendered.push('+');
} else {
rendered.push('-');
}
rendered.push_str(&self.starts[i].to_string());
rendered.push(',');
rendered.push_str(&self.linecounts[i].to_string());
rendered.push(' ');
}
rendered.push_str(&self.ats);
if let Some(title) = &self.title {
rendered.push(' ');
rendered.push_str(BOLD);
if let Some(last_start) = self.starts.last().cloned() {
// Skip this number of context lines to end up at the first
// modified line. There are usually three context lines. If
// people start complaining we'll have to detect the actual
// number.
let context_lines_skip = 3;
rendered.push_str(&hyperlink(title, url, last_start + context_lines_skip));
} else {
return Err(format!(
"HunkHeader has no start lines when rendering title: {:?}",
self
));
}
}
rendered.push_str(NORMAL);
return Ok(rendered);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_non_hunk_header() {
assert_eq!(None, HunkHeader::parse("This is not a hunk header"));
assert_eq!(None, HunkHeader::parse(""));
}
#[test]
fn test_simple_hunk_header() {
assert_eq!(
Some(HunkHeader {
ats: "@@".to_string(),
starts: vec![1, 1],
linecounts: vec![2, 2],
title: None,
}),
HunkHeader::parse("@@ -1,2 +1,2 @@")
);
}
#[test]
fn test_hunk_header_with_title() {
assert_eq!(
Some(HunkHeader {
ats: "@@".to_string(),
starts: vec![1, 1],
linecounts: vec![2, 2],
title: Some("Hello there".to_string()),
}),
HunkHeader::parse("@@ -1,2 +1,2 @@ Hello there")
);
}
#[test]
fn test_hunk_header_with_spaced_title() {
assert_eq!(
Some(HunkHeader {
ats: "@@".to_string(),
starts: vec![1, 1],
linecounts: vec![2, 2],
title: Some("Hello there".to_string()),
}),
HunkHeader::parse("@@ -1,2 +1,2 @@ Hello there")
);
}
#[test]
fn test_hunk_header_with_default_linecounts() {
assert_eq!(
Some(HunkHeader {
ats: "@@".to_string(),
starts: vec![5, 6],
linecounts: vec![1, 1],
title: None,
}),
HunkHeader::parse("@@ -5 +6 @@")
);
}
#[test]
fn test_hunk_header_with_multiple_olds() {
assert_eq!(
Some(HunkHeader {
ats: "@@@".to_string(),
starts: vec![1, 3, 5],
linecounts: vec![2, 4, 6],
title: None,
}),
HunkHeader::parse("@@@ -1,2 -3,4 +5,6 @@@")
);
}
}