Skip to content

Commit 7aa5a32

Browse files
committed
Preserve block quotes
Teach commitmsgfmt to recognize Usenet block quotes and preserve them as-is, thereby enabling quotes to safely appear outside of a literal context. Users that wish to reflow a quote are advised to do so via another tool, like fmt(1) or par(1). References: https://en.wikipedia.org/wiki/Usenet_quoting#Canonical_quoting References: https://gitlab.com/mkjeldsen/commitmsgfmt/-/issues/6
1 parent 9715e91 commit 7aa5a32

File tree

6 files changed

+245
-7
lines changed

6 files changed

+245
-7
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ understanding of patterns often seen in commit messages.
55

66
## Unreleased
77

8+
- #6: Recognize lines that begin with `>` as _block quotes_ and preserve them
9+
in their entirety, and allow them to follow a preceding paragraph without the
10+
empty line that is otherwise usually required.
11+
812
- If `--width` is specified multiple times, ignore all but the last occurrence.
913

1014
## 1.5.0 - 2022-07-30

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@ In summary, `commitmsgfmt`
3131
paragraph;
3232

3333
- reflows and wraps all other prose at the specified max width, defaulting to
34-
72 characters based on Time Pope's recommendation from 2008 [[tpope]];
34+
72 characters based on Tim Pope's recommendation from 2008 [[tpope]];
3535

3636
- properly indents continuation lines in numbered and unnumbered lists,
3737
recognizing several different list styles;
3838

39-
- exempts comments; text indented at least 4 spaces or 1 tab; and "trailers"
40-
(`Signed-off-by:`);
39+
- exempts comments; text indented at least 4 spaces or 1 tab; "trailers"
40+
(`Signed-off-by:`); and block quotes;
4141

4242
- assumes UTF-8 encoded input but can gracefully degrade to ISO-8859-1
4343
("latin1") which has been observed in the Linux kernel;

doc/commitmsgfmt.1.adoc

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ those cases and avoid them by preventing wrapping: it will
9393

9494
* join a sequence of footnote references to their preceding word, so the
9595
references both preserve their context and don't degenerate into _list
96-
items_;
96+
items_.
97+
98+
_Block quotes_ are exempt from the requirement of surrounding blank lines and
99+
will never be considered to belong to a paragraph. A block quote embedded
100+
inside a paragraph has the same effect on that paragraph as an empty line has.
97101

98102
=== Subject line
99103

@@ -195,6 +199,39 @@ literals.
195199
A line starting with one tab or four spaces is considered a _literal_. Literals
196200
are printed verbatim, making them suitable for listings and tables.
197201

202+
=== Block quote
203+
204+
A line starting with a greater-than sign (*>*) is considered a _block quote_:
205+
206+
----
207+
Git's Web site claims:
208+
> Git is easy to learn and has a tiny footprint with lightning fast
209+
> performance. It outclasses SCM tools like Subversion, CVS, Perforce, and
210+
> ClearCase with features like cheap local branching, convenient staging areas,
211+
> and multiple workflows.
212+
----
213+
214+
Block quotes are printed verbatim; they are not wrapped, nor are quote markers
215+
in any way normalized or aligned.
216+
217+
[TIP]
218+
====
219+
If you wish to reflow a block quote, Vim's *gq* command does a decent job.
220+
Alternatively, consider delegating to *fmt*(1). For example, the following Vim
221+
Normal mode command instructs *fmt*(1) to reflow every line starting with *>*
222+
in the cursor's paragraph to 72 columns:
223+
224+
----
225+
vip:!fmt -w72 -p'>'
226+
----
227+
====
228+
229+
Unlike other constructs a block quote may be embedded inside a _paragraph_ with
230+
no preceding or following blank line; the block quote will not be folded into
231+
the paragraph and the paragraph will otherwise observe standard behavior. This
232+
enables a common pattern of immediately preceding the block quote with an
233+
author attribution, illustrated above.
234+
198235
=== Comment
199236

200237
A line starting with the *core.commentChar* character, or a hash sign (*#*)
@@ -269,6 +306,9 @@ Given input
269306
subject
270307
foo baar -- baz qux wupwupwup [1][2] [wup]
271308
309+
hex:
310+
> 0 1 2 3 4 5 6 7 8 9 a b c d e f
311+
272312
- foo
273313
1. foo bar
274314
baz
@@ -289,6 +329,9 @@ baar --
289329
baz qux
290330
wupwupwup [1][2] [wup]
291331
332+
hex:
333+
> 0 1 2 3 4 5 6 7 8 9 a b c d e f
334+
292335
- foo
293336
1. foo bar
294337
baz

src/commitmsgfmt.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ impl CommitMsgFmt {
3737
fn reflow_into(&self, buf: &mut String, msg: &[Token]) {
3838
for tok in msg {
3939
match *tok {
40-
Comment(ref s) | Literal(ref s) | Scissored(ref s) | Trailer(ref s) => {
40+
BlockQuote(ref s) | Comment(ref s) | Literal(ref s) | Scissored(ref s)
41+
| Trailer(ref s) => {
4142
buf.push_str(s);
4243
}
4344
ListItem(ref indent, ref li, ref s) => {
@@ -214,6 +215,75 @@ foo
214215
assert_eq!(filter(72, &input), expected);
215216
}
216217

218+
#[test]
219+
fn preserves_block_quote() {
220+
let input = "
221+
foo
222+
223+
> block quote
224+
paragraph
225+
";
226+
227+
let expected = input;
228+
229+
assert_eq!(filter(72, &input), expected);
230+
}
231+
232+
#[test]
233+
fn preserves_block_quote_with_attribution() {
234+
let input = "
235+
foo
236+
237+
author wrote:
238+
> block quote
239+
paragraph
240+
";
241+
242+
let expected = input;
243+
244+
assert_eq!(filter(72, &input), expected);
245+
}
246+
247+
#[test]
248+
fn preserves_multiline_block_quote() {
249+
let input = "
250+
xx-xxxxxx xxxx xxxxxxx xxxxxxxxxxxxxx
251+
252+
xxxx xxxxxx xxxxxxx xxxxx xx xxx xxx -x xxxxxx xxxx xxxx-xx-xxxxx, xxxxx
253+
xxxxxxxxx xxxx xxxxxxx xxxxxxxxxxxxxx. xxxx xxx xxxxxxx:
254+
255+
> ```
256+
> -x xx --xx-xxxx
257+
> xxxxxxxx xxxxxxx xxx xxxxxxx xxxxxxxxxxxxxx xxx xxxxxxxxxxxxxxxx
258+
> xxxxxxx xx xxx xxxxxxxx. xxxx xx
259+
> ```
260+
261+
xxx xxxxxxx xxxxxxxx xx `xxxx` xx xx xxxxxx xxxxxxx xxxxxxxxxxxxxx.
262+
";
263+
264+
let expected = "
265+
xx-xxxxxx xxxx xxxxxxx xxxxxxxxxxxxxx
266+
267+
xxxx xxxxxx xxxxxxx xxxxx xx
268+
xxx xxx -x xxxxxx xxxx
269+
xxxx-xx-xxxxx, xxxxx xxxxxxxxx
270+
xxxx xxxxxxx xxxxxxxxxxxxxx.
271+
xxxx xxx xxxxxxx:
272+
273+
> ```
274+
> -x xx --xx-xxxx
275+
> xxxxxxxx xxxxxxx xxx xxxxxxx xxxxxxxxxxxxxx xxx xxxxxxxxxxxxxxxx
276+
> xxxxxxx xx xxx xxxxxxxx. xxxx xx
277+
> ```
278+
279+
xxx xxxxxxx xxxxxxxx xx `xxxx`
280+
xx xx xxxxxx xxxxxxx
281+
xxxxxxxxxxxxxx.
282+
";
283+
284+
assert_eq!(filter(30, &input), expected);
285+
}
286+
217287
#[test]
218288
fn formats_footnotes() {
219289
let msg = "

src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ Some text is exempt from wrapping:
146146
behaviour necessitates a laxer limit on its length to avoid rejecting too
147147
many valid subjects.
148148
149-
- Text indented at least 4 spaces or 1 tab, and trailers, are printed unchanged."#,
149+
- Text indented at least 4 spaces or 1 tab; trailers; and block quotes are
150+
printed unchanged."#,
150151
)
151152
.value_name("WIDTH"),
152153
)

src/parser.rs

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub enum Token<'input> {
2323
Subject(&'input str),
2424
Scissored(&'input str),
2525
Trailer(&'input str),
26+
BlockQuote(&'input str),
2627
VerticalSpace,
2728
}
2829

@@ -63,7 +64,9 @@ pub fn parse(input: &str, comment_char: char) -> Vec<Token> {
6364
toks.push(Token::Trailer(line));
6465
} else if let Some(y) = match toks.last_mut() {
6566
Some(&mut Token::Footnote(_, ref mut b)) => extend_prose_buffer_with_line(b, line),
66-
Some(&mut Token::Paragraph(ref mut b)) => extend_prose_buffer_with_line(b, line),
67+
Some(&mut Token::Paragraph(ref mut b)) => {
68+
line_as_line_block_quote(line).or_else(|| extend_prose_buffer_with_line(b, line))
69+
}
6770
Some(&mut Token::ListItem(_, _, ref mut b)) => {
6871
line_as_list_item(line).or_else(|| extend_prose_buffer_with_line(b, line))
6972
}
@@ -72,6 +75,8 @@ pub fn parse(input: &str, comment_char: char) -> Vec<Token> {
7275
Some(tok)
7376
} else if is_line_indented(line) {
7477
Some(Token::Literal(line))
78+
} else if let Some(tok) = line_as_line_block_quote(line) {
79+
Some(tok)
7580
} else {
7681
px = false;
7782
Some(Token::Paragraph(line.trim().into()))
@@ -313,6 +318,14 @@ fn line_as_list_item(line: &str) -> Option<Token> {
313318
})
314319
}
315320

321+
fn line_as_line_block_quote(line: &str) -> Option<Token> {
322+
if line.starts_with('>') {
323+
Some(Token::BlockQuote(line))
324+
} else {
325+
None
326+
}
327+
}
328+
316329
#[cfg(test)]
317330
mod tests {
318331
use super::Token::*;
@@ -611,6 +624,113 @@ some other paragraph
611624
);
612625
}
613626

627+
#[test]
628+
fn parses_block_quote_verbatim() {
629+
assert_eq!(
630+
parse(
631+
"
632+
some subject
633+
634+
some paragraph
635+
636+
> some block quote
637+
638+
some other paragraph
639+
"
640+
),
641+
[
642+
VerticalSpace,
643+
Subject("some subject"),
644+
VerticalSpace,
645+
Paragraph("some paragraph".into()),
646+
VerticalSpace,
647+
BlockQuote("> some block quote"),
648+
VerticalSpace,
649+
Paragraph("some other paragraph".into()),
650+
],
651+
);
652+
}
653+
654+
#[test]
655+
fn parses_nested_block_quotes_verbatim() {
656+
assert_eq!(
657+
parse(
658+
"
659+
some subject
660+
661+
some paragraph
662+
663+
> > some block quote
664+
665+
some other paragraph
666+
"
667+
),
668+
[
669+
VerticalSpace,
670+
Subject("some subject"),
671+
VerticalSpace,
672+
Paragraph("some paragraph".into()),
673+
VerticalSpace,
674+
BlockQuote("> > some block quote"),
675+
VerticalSpace,
676+
Paragraph("some other paragraph".into()),
677+
],
678+
);
679+
}
680+
681+
#[test]
682+
fn parses_nested_block_quotes_ignoring_quote_marker_spacing_and_quote_levels() {
683+
assert_eq!(
684+
parse(
685+
"
686+
some subject
687+
688+
some paragraph
689+
690+
>>>> >>> >> some block quote
691+
692+
some other paragraph
693+
"
694+
),
695+
[
696+
VerticalSpace,
697+
Subject("some subject"),
698+
VerticalSpace,
699+
Paragraph("some paragraph".into()),
700+
VerticalSpace,
701+
BlockQuote(">>>> >>> >> some block quote"),
702+
VerticalSpace,
703+
Paragraph("some other paragraph".into()),
704+
],
705+
);
706+
}
707+
708+
#[test]
709+
fn parses_block_quote_with_immediately_preceding_paragraph_as_attribution_leaving_no_vertical_space(
710+
) {
711+
assert_eq!(
712+
parse(
713+
"
714+
some subject
715+
716+
some attribution paragraph
717+
> some block quote
718+
719+
some other paragraph
720+
"
721+
),
722+
[
723+
VerticalSpace,
724+
Subject("some subject"),
725+
VerticalSpace,
726+
Paragraph("some attribution paragraph".into()),
727+
BlockQuote("> some block quote"),
728+
VerticalSpace,
729+
Paragraph("some other paragraph".into()),
730+
],
731+
);
732+
}
733+
614734
#[test]
615735
fn parses_trailers() {
616736
// Trailers look like HTTP or email headers but are not formally

0 commit comments

Comments
 (0)