gix_object/commit/message/
body.rs1use std::{borrow::Cow, ops::Deref};
2
3use crate::{
4 bstr::{BStr, BString, ByteSlice, ByteVec},
5 commit::message::BodyRef,
6};
7
8pub struct Trailers<'a> {
12 pub(crate) cursor: &'a [u8],
13}
14
15pub struct MessageBlocks<'a> {
17 cursor: &'a [u8],
18 trailer_start: usize,
19}
20
21pub struct MessageBlock<'a> {
23 pub message: &'a BStr,
25 trailers: &'a [u8],
26}
27
28#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct TrailerRef<'a> {
32 #[cfg_attr(feature = "serde", serde(borrow))]
34 pub token: &'a BStr,
35 #[cfg_attr(feature = "serde", serde(borrow))]
39 pub value: Cow<'a, BStr>,
40}
41
42const GIT_GENERATED_PREFIXES: [&[u8]; 2] = [b"Signed-off-by: ", b"(cherry picked from commit "];
47
48#[derive(Clone, Copy)]
49struct Line<'a> {
54 text: &'a [u8],
56 start: usize,
58}
59
60fn trim_line_ending(mut line: &[u8]) -> &[u8] {
62 if let Some(stripped) = line.strip_suffix(b"\n") {
63 line = stripped;
64 if let Some(stripped) = line.strip_suffix(b"\r") {
65 line = stripped;
66 }
67 } else if let Some(stripped) = line.strip_suffix(b"\r") {
68 line = stripped;
69 }
70 line
71}
72
73fn lines(input: &[u8]) -> Vec<Line<'_>> {
80 let mut start = 0;
81 input
82 .lines_with_terminator()
83 .map(|raw| {
84 let line = Line {
85 text: trim_line_ending(raw),
86 start,
87 };
88 start += raw.len();
89 line
90 })
91 .collect()
92}
93
94fn find_separator(line: &[u8]) -> Option<usize> {
100 let mut whitespace_found = false;
101 for (idx, byte) in line.iter().copied().enumerate() {
102 if byte == b':' {
103 return Some(idx);
104 }
105 if !whitespace_found && (byte.is_ascii_alphanumeric() || byte == b'-') {
106 continue;
107 }
108 if idx != 0 && matches!(byte, b' ' | b'\t') {
109 whitespace_found = true;
110 continue;
111 }
112 break;
113 }
114 None
115}
116
117fn parse_trailer_line(line: &[u8]) -> Option<(&BStr, usize)> {
127 if line.first().is_some_and(u8::is_ascii_whitespace) {
128 return None;
129 }
130 let separator = find_separator(line)?;
131 (separator > 0).then_some((line[..separator].trim().as_bstr(), separator))
132}
133
134fn is_blank_line(line: &[u8]) -> bool {
135 line.iter().all(u8::is_ascii_whitespace)
136}
137
138fn is_recognized_prefix(line: &[u8]) -> bool {
139 GIT_GENERATED_PREFIXES.iter().any(|prefix| line.starts_with(prefix))
140}
141
142fn unfold_value(value: &[u8]) -> Cow<'_, BStr> {
149 let mut physical_lines = value.lines().peekable();
150 let Some(first_line) = physical_lines.next() else {
151 return Cow::Borrowed(b"".as_bstr());
152 };
153
154 if physical_lines.peek().is_none() {
155 return Cow::Borrowed(first_line.trim().as_bstr());
156 }
157
158 let mut out = BString::from(first_line.trim());
159 for line in physical_lines {
160 let line = line.trim();
161 if line.is_empty() {
162 continue;
163 }
164 if !out.is_empty() {
165 out.push_byte(b' ');
166 }
167 out.extend_from_slice(line);
168 }
169 Cow::Owned(out)
170}
171
172struct TrailerLine<'a> {
173 token: &'a BStr,
174 separator: usize,
175 len: usize,
176}
177
178fn trailer_at_start(cursor: &[u8]) -> Option<TrailerLine<'_>> {
179 let line = cursor.lines_with_terminator().next()?;
180 let (token, separator) = parse_trailer_line(trim_line_ending(line))?;
181 let mut len = line.len();
182 let mut rest = &cursor[len..];
183 while let Some(next_line) = rest.lines_with_terminator().next() {
184 let next_text = trim_line_ending(next_line);
185 if is_blank_line(next_text) || !next_text.first().is_some_and(u8::is_ascii_whitespace) {
186 break;
187 }
188 len += next_line.len();
189 rest = &rest[next_line.len()..];
190 }
191 Some(TrailerLine { token, separator, len })
192}
193
194fn trailer_block_start(body: &[u8]) -> Option<usize> {
206 fn accepts_as_trailer_block(recognized_prefix: bool, trailer_lines: usize, non_trailer_lines: usize) -> bool {
210 (trailer_lines > 0 && non_trailer_lines == 0) || (recognized_prefix && trailer_lines * 3 >= non_trailer_lines)
211 }
212
213 let lines = lines(body);
214 let mut recognized_prefix = false;
215 let mut trailer_lines = 0usize;
216 let mut non_trailer_lines = 0usize;
217 let mut possible_continuation_lines = 0usize;
218 let mut saw_non_blank_line = false;
219
220 for idx in (0..lines.len()).rev() {
221 let line = &lines[idx];
222 if is_blank_line(line.text) {
223 if !saw_non_blank_line {
224 continue;
225 }
226 non_trailer_lines += possible_continuation_lines;
227 return accepts_as_trailer_block(recognized_prefix, trailer_lines, non_trailer_lines).then_some(
228 idx.checked_sub(1)
229 .map_or(0, |prev| lines[prev].start + lines[prev].text.len()),
230 );
231 }
232
233 saw_non_blank_line = true;
234 if is_recognized_prefix(line.text) {
235 trailer_lines += 1;
236 possible_continuation_lines = 0;
237 recognized_prefix = true;
238 continue;
239 }
240
241 if parse_trailer_line(line.text).is_some() {
242 trailer_lines += 1;
243 possible_continuation_lines = 0;
244 continue;
245 }
246
247 if line.text.first().is_some_and(u8::is_ascii_whitespace) {
248 possible_continuation_lines += 1;
249 continue;
250 }
251
252 non_trailer_lines += 1 + possible_continuation_lines;
253 possible_continuation_lines = 0;
254 }
255
256 non_trailer_lines += possible_continuation_lines;
257 accepts_as_trailer_block(recognized_prefix, trailer_lines, non_trailer_lines).then_some(0)
258}
259
260impl<'a> Iterator for Trailers<'a> {
261 type Item = TrailerRef<'a>;
262
263 fn next(&mut self) -> Option<Self::Item> {
264 while !self.cursor.is_empty() {
265 if let Some(trailer) = trailer_at_start(self.cursor) {
266 let value = unfold_value(&self.cursor[trailer.separator + 1..trailer.len]);
267 self.cursor = &self.cursor[trailer.len..];
268 return Some(TrailerRef {
269 token: trailer.token,
270 value,
271 });
272 }
273 let consumed = self.cursor.lines_with_terminator().next()?.len();
274 self.cursor = &self.cursor[consumed..];
275 }
276 None
277 }
278}
279
280impl<'a> Iterator for MessageBlocks<'a> {
281 type Item = MessageBlock<'a>;
282
283 fn next(&mut self) -> Option<Self::Item> {
284 if self.cursor.is_empty() {
285 return None;
286 }
287
288 let mut message_len = self.trailer_start;
289 while message_len < self.cursor.len() && trailer_at_start(&self.cursor[message_len..]).is_none() {
290 message_len += self.cursor[message_len..].lines_with_terminator().next()?.len();
291 }
292
293 let trailer_start = message_len;
294 while let Some(trailer) = trailer_at_start(&self.cursor[message_len..]) {
295 message_len += trailer.len;
296 }
297 let block_len = message_len;
298 let block = MessageBlock {
299 message: self.cursor[..trailer_start].as_bstr(),
300 trailers: &self.cursor[trailer_start..block_len],
301 };
302 self.cursor = &self.cursor[block_len..];
303 self.trailer_start = 0;
304 Some(block)
305 }
306}
307
308impl<'a> MessageBlock<'a> {
309 pub fn trailers(&self) -> Trailers<'a> {
311 Trailers { cursor: self.trailers }
312 }
313}
314
315impl<'a> BodyRef<'a> {
316 pub fn from_bytes(body: &'a [u8]) -> Self {
318 BodyRef {
319 body: body.as_bstr(),
320 trailer_start: trailer_block_start(body).unwrap_or(body.len()),
321 }
322 }
323
324 pub fn without_trailer(&self) -> &'a BStr {
328 self.body[..self.trailer_start].as_bstr()
329 }
330
331 pub fn trailers(&self) -> Trailers<'a> {
333 Trailers {
334 cursor: &self.body[self.trailer_start..],
335 }
336 }
337
338 pub fn message_blocks(&self) -> MessageBlocks<'a> {
343 MessageBlocks {
344 cursor: self.body,
345 trailer_start: self.trailer_start,
346 }
347 }
348}
349
350impl AsRef<BStr> for BodyRef<'_> {
351 fn as_ref(&self) -> &BStr {
352 self.without_trailer()
353 }
354}
355
356impl Deref for BodyRef<'_> {
357 type Target = BStr;
358
359 fn deref(&self) -> &Self::Target {
360 self.without_trailer()
361 }
362}
363
364impl TrailerRef<'_> {
366 pub fn is_signed_off_by(&self) -> bool {
368 self.token.eq_ignore_ascii_case(b"Signed-off-by")
369 }
370
371 pub fn is_co_authored_by(&self) -> bool {
373 self.token.eq_ignore_ascii_case(b"Co-authored-by")
374 }
375
376 pub fn is_assisted_by(&self) -> bool {
378 self.token.eq_ignore_ascii_case(b"Assisted-by")
379 }
380
381 pub fn is_acked_by(&self) -> bool {
383 self.token.eq_ignore_ascii_case(b"Acked-by")
384 }
385
386 pub fn is_reviewed_by(&self) -> bool {
388 self.token.eq_ignore_ascii_case(b"Reviewed-by")
389 }
390
391 pub fn is_tested_by(&self) -> bool {
393 self.token.eq_ignore_ascii_case(b"Tested-by")
394 }
395
396 pub fn is_attribution(&self) -> bool {
399 self.is_signed_off_by()
400 || self.is_co_authored_by()
401 || self.is_assisted_by()
402 || self.is_acked_by()
403 || self.is_reviewed_by()
404 || self.is_tested_by()
405 }
406}
407
408impl<'a> Trailers<'a> {
410 pub fn signed_off_by(self) -> impl Iterator<Item = TrailerRef<'a>> {
412 self.filter(TrailerRef::is_signed_off_by)
413 }
414
415 pub fn co_authored_by(self) -> impl Iterator<Item = TrailerRef<'a>> {
417 self.filter(TrailerRef::is_co_authored_by)
418 }
419
420 pub fn assisted_by(self) -> impl Iterator<Item = TrailerRef<'a>> {
422 self.filter(TrailerRef::is_assisted_by)
423 }
424
425 pub fn attributions(self) -> impl Iterator<Item = TrailerRef<'a>> {
428 self.filter(TrailerRef::is_attribution)
429 }
430
431 pub fn authors(self) -> impl Iterator<Item = TrailerRef<'a>> {
433 self.filter(|trailer| trailer.is_signed_off_by() || trailer.is_co_authored_by())
434 }
435}
436
437#[cfg(test)]
438mod test_parse_trailer {
439 use super::*;
440
441 fn parse(input: &str) -> TrailerRef<'_> {
442 Trailers {
443 cursor: input.as_bytes(),
444 }
445 .next()
446 .expect("a trailer to be parsed")
447 }
448
449 #[test]
450 fn simple_newline() {
451 assert_eq!(
452 parse("foo: bar\n"),
453 TrailerRef {
454 token: "foo".into(),
455 value: b"bar".as_bstr().into()
456 }
457 );
458 }
459
460 #[test]
461 fn whitespace_around_separator_is_normalized() {
462 assert_eq!(
463 parse("foo : bar"),
464 TrailerRef {
465 token: "foo".into(),
466 value: b"bar".as_bstr().into()
467 }
468 );
469 }
470
471 #[test]
472 fn trailing_whitespace_after_value_is_trimmed() {
473 assert_eq!(
474 parse("hello-foo: bar there \n"),
475 TrailerRef {
476 token: "hello-foo".into(),
477 value: b"bar there".as_bstr().into()
478 }
479 );
480 }
481
482 #[test]
483 fn invalid_token_is_not_a_trailer() {
484 assert_eq!(
485 Trailers {
486 cursor: "🤗: 🎉".as_bytes()
487 }
488 .next(),
489 None
490 );
491 }
492
493 #[test]
494 fn simple_newline_windows() {
495 assert_eq!(
496 parse("foo: bar\r\n"),
497 TrailerRef {
498 token: "foo".into(),
499 value: b"bar".as_bstr().into()
500 }
501 );
502 }
503
504 #[test]
505 fn folded_value_is_unfolded() {
506 assert_eq!(
507 parse("foo: bar\n continued\r\n here"),
508 TrailerRef {
509 token: "foo".into(),
510 value: b"bar continued here".as_bstr().into()
511 }
512 );
513 }
514}