wowlab_tidy/languages/rust/rules/docs/
const_needs_doc.rs1use ra_ap_syntax::{
2 AstNode,
3 ast::{self, HasAttrs, HasDocComments, HasName, HasVisibility, LiteralKind, VisibilityKind},
4};
5
6use crate::{AstCtx, Example, Violation};
7
8#[rustfmt::skip]
10const EXAMPLES: &[Example] = &[
11 Example {
12 label: "undocumented const with numeric literal",
13 code: "const TIMEOUT_SECS: u64 = 30;",
14 pass: false,
15 },
16 Example {
17 label: "undocumented static with string literal",
18 code: "static ENDPOINT: &str = \"primary\";",
19 pass: false,
20 },
21 Example {
22 label: "literal nested in a call",
23 code: "const RETRY: Duration = Duration::from_secs(30);",
24 pass: false,
25 },
26 Example {
27 label: "doc comment explains the value",
28 code: "/// Upstream aborts after thirty seconds.\nconst TIMEOUT_SECS: u64 = 30;",
29 pass: true,
30 },
31 Example {
32 label: "line comment above explains the value",
33 code: "// Matches the upstream timeout policy.\nconst TIMEOUT_SECS: u64 = 30;",
34 pass: true,
35 },
36 Example {
37 label: "comment above the attribute",
38 code: "// Fixture table kept verbatim.\n#[rustfmt::skip]\nconst NAMES: &[&str] = &[\"a\"];",
39 pass: true,
40 },
41 Example {
42 label: "pub const is pub_api_docs territory",
43 code: "pub const TIMEOUT_SECS: u64 = 30;",
44 pass: true,
45 },
46 Example {
47 label: "pub(crate) const still needs a doc",
48 code: "pub(crate) const TIMEOUT_SECS: u64 = 30;",
49 pass: false,
50 },
51 Example {
52 label: "no literal in initializer",
53 code: "const SIZE: usize = std::mem::size_of::<u64>();",
54 pass: true,
55 },
56 Example {
57 label: "const in test module",
58 code: "#[cfg(test)]\nmod tests {\n const TIMEOUT_SECS: u64 = 30;\n}",
59 pass: true,
60 },
61 Example {
62 label: "function-local const is not module-level",
63 code: "fn f() -> u64 {\n const LOCAL: u64 = 30;\n LOCAL\n}",
64 pass: true,
65 },
66];
67
68crate::ast_rule!(
69 const_needs_doc,
70 "Require a doc or line comment on private consts and statics holding literal values.",
71 "Magic values need context: why the value was chosen and what depends on it (M-DOCUMENTED-MAGIC).",
72 Low,
73);
74
75fn check_const_needs_doc(ctx: &AstCtx<'_>) -> Vec<Violation> {
76 let mut violations = Vec::new();
77
78 for item in ctx.nodes::<ast::Const>() {
79 check_literal_item(ctx, &item, "const", item.body(), &mut violations);
80 }
81
82 for item in ctx.nodes::<ast::Static>() {
83 check_literal_item(ctx, &item, "static", item.body(), &mut violations);
84 }
85
86 violations
87}
88
89fn check_literal_item<N>(
90 ctx: &AstCtx<'_>,
91 item: &N,
92 kind: &str,
93 body: Option<ast::Expr>,
94 violations: &mut Vec<Violation>,
95) where
96 N: AstNode + HasAttrs + HasDocComments + HasName + HasVisibility,
97{
98 let Some(name) = item.name() else {
99 return;
100 };
101
102 if is_exempt_item(ctx, item)
103 || item
104 .visibility()
105 .is_some_and(|visibility| matches!(visibility.kind(), VisibilityKind::Pub))
106 || has_docs(item)
107 || body.is_none_or(|body| !contains_magic_literal(&body))
108 || has_comment_above(ctx, item, &name)
109 {
110 return;
111 }
112
113 violations.push(ctx.violation(
114 &name,
115 format!(
116 "{kind} `{}` holds a literal value without a doc or comment explaining it",
117 name.text()
118 ),
119 ));
120}
121
122fn is_exempt_item<N>(ctx: &AstCtx<'_>, item: &N) -> bool
123where
124 N: AstNode,
125{
126 ctx.is_in_test(item)
127 || item.syntax().ancestors().skip(1).any(|ancestor| {
128 ast::Fn::can_cast(ancestor.kind())
129 || ast::AssocItemList::can_cast(ancestor.kind())
130 || ast::ExternItemList::can_cast(ancestor.kind())
131 })
132}
133
134fn has_docs(item: &impl HasDocComments) -> bool {
135 item.doc_comments().next().is_some()
136 || item
137 .attrs()
138 .any(|attr| attr.simple_name().as_deref() == Some("doc"))
139}
140
141fn has_comment_above<N>(ctx: &AstCtx<'_>, item: &N, name: &ast::Name) -> bool
142where
143 N: AstNode + HasAttrs,
144{
145 let first_line = item
146 .attrs()
147 .next()
148 .map_or_else(|| ctx.line_of(name), |attr| ctx.line_of(&attr));
149
150 ctx.file
151 .line(first_line.saturating_sub(1))
152 .is_some_and(|line| line.trim().starts_with("//"))
153}
154
155fn contains_magic_literal(expr: &ast::Expr) -> bool {
156 expr.syntax()
157 .descendants()
158 .filter_map(ast::Literal::cast)
159 .any(|literal| {
160 matches!(
161 literal.kind(),
162 LiteralKind::IntNumber(_) | LiteralKind::FloatNumber(_) | LiteralKind::String(_)
163 )
164 })
165}
166
167crate::tidy_ast_test!(check_const_needs_doc, {
168 crate::example_tests!(EXAMPLES, check_const_needs_doc);
169});