Skip to main content

wowlab_tidy/languages/rust/rules/docs/
first_doc_sentence.rs

1use crate::{Example, FileCtx, Violation, infra::parse, violation};
2
3/// Pass/fail cases for `example_tests!`.
4#[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});