wowlab_tidy/languages/rust/rules/docs/
first_doc_sentence.rs1use crate::{Example, FileCtx, Violation, infra::parse, violation};
2
3#[rustfmt::skip]
5const EXAMPLES: &[Example] = &[
6 Example {
7 label: "short first sentence",
8 code: "/// Returns the parsed value.\nfn f() {}",
9 pass: true,
10 },
11 Example {
12 label: "first sentence over the word budget",
13 code: "/// This extremely long summary sentence keeps going and going with far too many words to fit the budget.",
14 pass: false,
15 },
16 Example {
17 label: "first sentence spills onto the next line",
18 code: "/// This summary continues\n/// onto the next line.",
19 pass: false,
20 },
21 Example {
22 label: "long detail after a short summary",
23 code: "/// Short summary.\n///\n/// Detail sentences after the summary can be as long as they want to be without limits.",
24 pass: true,
25 },
26 Example {
27 label: "inner doc short summary",
28 code: "//! Module docs are covered.",
29 pass: true,
30 },
31 Example {
32 label: "inner doc summary spills",
33 code: "//! Module summary that drifts\n//! across lines.",
34 pass: false,
35 },
36 Example {
37 label: "single line without terminator stays in budget",
38 code: "/// Returns `None`",
39 pass: true,
40 },
41 Example {
42 label: "sentence ends before a code fence",
43 code: "/// Sums things.\n/// ```\n/// let total = sum(items). More words inside fences never matter at all here\n/// ```",
44 pass: true,
45 },
46 Example {
47 label: "heading-only block is skipped",
48 code: "/// # Safety",
49 pass: true,
50 },
51 Example {
52 label: "plain comments can ramble",
53 code: "// this is not a doc comment so it can ramble on forever without any punctuation at all",
54 pass: true,
55 },
56 Example {
57 label: "empty first doc line delays the summary",
58 code: "///\n/// The summary arrives too late here.",
59 pass: false,
60 },
61 Example {
62 label: "dotted names are not sentence ends",
63 code: "/// Parses `foo.bar` fields quickly.",
64 pass: true,
65 },
66];
67
68crate::line_rule!(
69 first_doc_sentence,
70 "Require the first doc sentence to end on the first line within a word budget.",
71 "The first sentence becomes the rustdoc summary; long or spilling summaries break skimmable docs (M-FIRST-DOC-SENTENCE).",
72 Low,
73 params {
74 max_words: i64 = 15
75 },
76);
77
78fn check_first_doc_sentence(ctx: &FileCtx<'_>) -> Vec<Violation> {
79 let max_words = ctx.config.get_usize("rust_first_doc_sentence", &PARAMS[0]);
80 let mut out = Vec::new();
81
82 for block in doc_blocks(ctx.lines) {
83 check_block(ctx, &block, max_words, &mut out);
84 }
85
86 out
87}
88
89struct DocBlock<'a> {
90 start_line: usize,
91 lines: Vec<&'a str>,
92}
93
94fn doc_blocks<'a>(lines: &[&'a str]) -> Vec<DocBlock<'a>> {
95 let mut blocks = Vec::new();
96 let mut current: Option<DocBlock<'a>> = None;
97 let mut in_fence = false;
98
99 for (i, line) in lines.iter().enumerate() {
100 if let Some(raw) = parse::doc_comment_content(line.trim()) {
101 let text = raw.trim();
102 let block = current.get_or_insert_with(|| DocBlock {
103 start_line: i + 1,
104 lines: Vec::new(),
105 });
106
107 if parse::matches(text, "```") {
108 in_fence = !in_fence;
109 continue;
110 }
111
112 if !in_fence {
113 block.lines.push(text);
114 }
115 } else {
116 if let Some(block) = current.take() {
117 blocks.push(block);
118 }
119
120 in_fence = false;
121 }
122 }
123
124 if let Some(block) = current.take() {
125 blocks.push(block);
126 }
127
128 blocks
129}
130
131fn check_block(
132 ctx: &FileCtx<'_>,
133 block: &DocBlock<'_>,
134 max_words: usize,
135 out: &mut Vec<Violation>,
136) {
137 let Some(first) = block.lines.first().copied() else {
138 return;
139 };
140
141 if first.is_empty() {
142 if block.lines.iter().any(|line| !line.is_empty()) {
143 out.push(violation(
144 ctx.rel,
145 block.start_line,
146 "first doc sentence must start on the block's first line",
147 ));
148 }
149
150 return;
151 }
152
153 if parse::matches(first, '#') {
154 return;
155 }
156
157 let continues = block.lines.get(1).is_some_and(|line| !line.is_empty());
158
159 match first_sentence_word_count(first) {
160 Some(words) if words > max_words => out.push(violation(
161 ctx.rel,
162 block.start_line,
163 format!("first doc sentence has {words} words (max {max_words})"),
164 )),
165 Some(_) => {}
166 None if continues => out.push(violation(
167 ctx.rel,
168 block.start_line,
169 "first doc sentence must end on the block's first line",
170 )),
171 None => {
172 if first.split_whitespace().count() > max_words {
173 out.push(violation(
174 ctx.rel,
175 block.start_line,
176 "first doc sentence exceeds the word budget",
177 ));
178 }
179 }
180 }
181}
182
183fn first_sentence_word_count(text: &str) -> Option<usize> {
184 let mut words = 0;
185 let mut in_word = false;
186 let mut chars = text.chars().peekable();
187
188 while let Some(ch) = chars.next() {
189 if ch.is_whitespace() {
190 in_word = false;
191 } else if !in_word {
192 in_word = true;
193 words += 1;
194 }
195
196 if matches!(ch, '.' | '!' | '?') {
197 match chars.peek() {
198 None => return Some(words),
199 Some(next) if next.is_whitespace() => return Some(words),
200 Some(_) => {}
201 }
202 }
203 }
204
205 None
206}
207
208crate::tidy_test!(check_first_doc_sentence, {
209 crate::example_tests!(EXAMPLES, check_first_doc_sentence);
210});