Skip to main content

gix_object/commit/message/
body.rs

1use std::{borrow::Cow, ops::Deref};
2
3use crate::{
4    bstr::{BStr, BString, ByteSlice, ByteVec},
5    commit::message::BodyRef,
6};
7
8/// An iterator over trailers as parsed from a commit message body.
9///
10/// lines with parsing failures will be skipped
11pub struct Trailers<'a> {
12    pub(crate) cursor: &'a [u8],
13}
14
15/// An iterator over raw message portions and the contiguous trailers following each one.
16pub struct MessageBlocks<'a> {
17    cursor: &'a [u8],
18    trailer_start: usize,
19}
20
21/// A raw message portion and the contiguous trailers following it.
22pub struct MessageBlock<'a> {
23    /// The message bytes, including their original whitespace and line endings.
24    pub message: &'a BStr,
25    trailers: &'a [u8],
26}
27
28/// A trailer as parsed from the commit message body.
29#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct TrailerRef<'a> {
32    /// The name of the trailer, like "Signed-off-by", up to the separator `: `.
33    #[cfg_attr(feature = "serde", serde(borrow))]
34    pub token: &'a BStr,
35    /// The value right after the separator `: `, with leading and trailing whitespace trimmed.
36    /// Multi-line values are unfolded to match `git interpret-trailers --parse`, which is when
37    /// this field is [`Cow::Owned`].
38    #[cfg_attr(feature = "serde", serde(borrow))]
39    pub value: Cow<'a, BStr>,
40}
41
42// Git treats these as built-in, recognized trailer prefixes when deciding whether a
43// trailing paragraph is a trailer block at all. The cherry-pick marker is special in
44// that it is not a `token: value` trailer, but it still contributes to Git's
45// recognized-prefix / 25% heuristic in `interpret-trailers`.
46const GIT_GENERATED_PREFIXES: [&[u8]; 2] = [b"Signed-off-by: ", b"(cherry picked from commit "];
47
48#[derive(Clone, Copy)]
49/// A physical line in the original message body.
50///
51/// `text` has its trailing line ending removed for parsing, while `start`
52/// points to the first byte of that line in the original `body` slice.
53struct Line<'a> {
54    /// The line contents without a trailing `\n` or `\r\n`.
55    text: &'a [u8],
56    /// Byte offset of the start of this line in the original body buffer.
57    start: usize,
58}
59
60/// Windows or linux line endings are supported here.
61fn 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
73/// Split `input` into physical lines while keeping enough information to map
74/// parser decisions back to the original byte slice.
75///
76/// This is different from using plain `.lines()` because trailer block detection
77/// needs normalized line contents for parsing *and* exact byte offsets to slice
78/// the original body at the eventual trailer boundary.
79fn 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
94/// Find the byte position of a Git trailer separator in `line`.
95///
96/// This recognizes the `:` that terminates a trailer token like `Acked-by: Alice`
97/// as well as the looser Git form with optional whitespace before the separator,
98/// like `Acked-by : Alice`.
99fn 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
117/// Parse a single physical trailer line.
118///
119/// Returns `None` if `line` is not a valid trailer line at all.
120///
121/// Returns `Some((token, separator_offset))` if parsing succeeds, where `token`
122/// is the normalized trailer token as a `BStr` and `separator_offset` is the
123/// byte offset of the `:` separator in the original `line`. Callers use that
124/// offset to slice out the raw value bytes, potentially including following
125/// continuation lines.
126fn 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
142/// Turn a raw trailer value, possibly spanning multiple physical lines, into
143/// the unfolded value Git would expose for parsing.
144///
145/// A single-line value is returned borrowed. If continuation lines are present,
146/// embedded newlines and leading continuation whitespace are collapsed into
147/// single spaces and the unfolded value is returned owned.
148fn 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
194/// Find the byte offset at which the trailer block begins in `body`.
195///
196/// Returns `None` if the trailing paragraph does not satisfy Git's trailer-block
197/// heuristic. Returns `Some(offset)`  if it does, where `offset` points into the
198/// original `body` slice at the first byte that belongs to the trailer block,
199/// including the separating blank line when one is present.
200///
201/// Internally this mirrors Git's backward scan: count trailer lines,
202/// non-trailer lines, continuation lines, and whether a recognized built-in
203/// prefix was seen, then apply the "all trailers" or recognized-prefix / 25%
204/// rule to the last paragraph of the body.
205fn trailer_block_start(body: &[u8]) -> Option<usize> {
206    /// Git accepts the trailing paragraph either if it is made entirely of
207    /// trailers, or if it contains at least one recognized built-in trailer
208    /// prefix and at least 25% of the paragraph consists of trailer lines.
209    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    /// Return the continuously parseable trailers immediately following [`message`](MessageBlock::message).
310    pub fn trailers(&self) -> Trailers<'a> {
311        Trailers { cursor: self.trailers }
312    }
313}
314
315impl<'a> BodyRef<'a> {
316    /// Parse `body` bytes into the trailer and the actual body.
317    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    /// Returns the body with the trailers stripped.
325    ///
326    /// You can iterate trailers with the [`trailers()`][BodyRef::trailers()] method.
327    pub fn without_trailer(&self) -> &'a BStr {
328        self.body[..self.trailer_start].as_bstr()
329    }
330
331    /// Return an iterator over the trailers parsed from the last paragraph of the body. Maybe empty.
332    pub fn trailers(&self) -> Trailers<'a> {
333        Trailers {
334            cursor: &self.body[self.trailer_start..],
335        }
336    }
337
338    /// Return the entire body as raw message portions, each followed by a continuously parseable trailer run.
339    ///
340    /// Message bytes aren't trimmed or normalized. Trailer-like lines outside the block accepted by Git's
341    /// trailer heuristic remain part of a message.
342    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
364/// Convenience methods
365impl TrailerRef<'_> {
366    /// Check if this trailer is a `Signed-off-by` trailer (case-insensitive).
367    pub fn is_signed_off_by(&self) -> bool {
368        self.token.eq_ignore_ascii_case(b"Signed-off-by")
369    }
370
371    /// Check if this trailer is a `Co-authored-by` trailer (case-insensitive).
372    pub fn is_co_authored_by(&self) -> bool {
373        self.token.eq_ignore_ascii_case(b"Co-authored-by")
374    }
375
376    /// Check if this trailer is an `Assisted-by` trailer (case-insensitive).
377    pub fn is_assisted_by(&self) -> bool {
378        self.token.eq_ignore_ascii_case(b"Assisted-by")
379    }
380
381    /// Check if this trailer is an `Acked-by` trailer (case-insensitive).
382    pub fn is_acked_by(&self) -> bool {
383        self.token.eq_ignore_ascii_case(b"Acked-by")
384    }
385
386    /// Check if this trailer is a `Reviewed-by` trailer (case-insensitive).
387    pub fn is_reviewed_by(&self) -> bool {
388        self.token.eq_ignore_ascii_case(b"Reviewed-by")
389    }
390
391    /// Check if this trailer is a `Tested-by` trailer (case-insensitive).
392    pub fn is_tested_by(&self) -> bool {
393        self.token.eq_ignore_ascii_case(b"Tested-by")
394    }
395
396    /// Check if this trailer represents any kind of authorship or attribution
397    /// (`Signed-off-by`, `Co-authored-by`, etc.).
398    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
408/// Convenience methods
409impl<'a> Trailers<'a> {
410    /// Filter trailers to only include `Signed-off-by` entries.
411    pub fn signed_off_by(self) -> impl Iterator<Item = TrailerRef<'a>> {
412        self.filter(TrailerRef::is_signed_off_by)
413    }
414
415    /// Filter trailers to only include `Co-authored-by` entries.
416    pub fn co_authored_by(self) -> impl Iterator<Item = TrailerRef<'a>> {
417        self.filter(TrailerRef::is_co_authored_by)
418    }
419
420    /// Filter trailers to only include `Assisted-by` entries.
421    pub fn assisted_by(self) -> impl Iterator<Item = TrailerRef<'a>> {
422        self.filter(TrailerRef::is_assisted_by)
423    }
424
425    /// Filter trailers to only include attribution-related entries.
426    /// (`Signed-off-by`, `Co-authored-by`, `Assisted-by`, `Acked-by`, `Reviewed-by`, `Tested-by`).
427    pub fn attributions(self) -> impl Iterator<Item = TrailerRef<'a>> {
428        self.filter(TrailerRef::is_attribution)
429    }
430
431    /// Filter trailers to only include authors from `Signed-off-by` and `Co-authored-by` entries.
432    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}