1use gobject_ast::model::{ExportMacro, FunctionAnnotation, PropertyAnnotation};
2
3use crate::{
4 ast_context::AstContext,
5 config::Config,
6 rules::{Category, Rule, Violation},
7};
8
9pub struct GiMissingSince;
10
11impl Rule for GiMissingSince {
12 fn name(&self) -> &'static str {
13 "gi_missing_since"
14 }
15
16 fn description(&self) -> &'static str {
17 "Detect public API with AVAILABLE_IN macros but missing or mismatched Since: annotations"
18 }
19
20 fn category(&self) -> Category {
21 Category::Introspection
22 }
23
24 fn requires_meson(&self) -> bool {
25 true
26 }
27
28 fn opt_in(&self) -> bool {
29 true
30 }
31
32 fn opt_in_reason(&self) -> Option<&'static str> {
33 Some("Only relevant to libraries maintaining GObject Introspection annotations")
34 }
35
36 fn check_all(
37 &self,
38 ast_context: &AstContext,
39 _config: &Config,
40 violations: &mut Vec<Violation>,
41 ) {
42 if !ast_context.has_public_private_info() {
43 return;
44 }
45
46 self.check_type_since(ast_context, violations);
47 self.check_functions_since(ast_context, violations);
48 self.check_property_since_consistency(ast_context, violations);
49 self.check_enum_value_inline_since(ast_context, violations);
50 }
51}
52
53impl GiMissingSince {
54 fn check_type_since(&self, ast_context: &AstContext, violations: &mut Vec<Violation>) {
55 for (path, file) in ast_context.iter_header_files() {
56 if !ast_context.is_public_header(path).unwrap_or(false) {
57 continue;
58 }
59
60 for gt in file.iter_all_gobject_types() {
61 let type_doc = ast_context.find_type_doc(>.type_name);
62
63 match gt.export_macros.iter().find(|m| m.version().is_some()) {
64 Some(
65 ExportMacro::DeprecatedIn(macro_ver)
66 | ExportMacro::DeprecatedInFor(macro_ver, _),
67 ) => {
68 let dep_ver = type_doc.and_then(|d| d.deprecated.as_ref().map(|(v, _)| v));
69 match dep_ver {
70 None => {
71 violations.push(self.violation_at(
72 path,
73 >.location,
74 format!(
75 "Type '{}' has DEPRECATED_IN_{}_{} but is missing a Deprecated: annotation",
76 gt.type_name,
77 macro_ver.major, macro_ver.minor,
78 ),
79 ));
80 }
81 Some(v) if v != macro_ver => {
82 violations.push(self.violation_at(
83 path,
84 >.location,
85 format!(
86 "Type '{}' has DEPRECATED_IN_{}_{} but Deprecated: says {}",
87 gt.type_name, macro_ver.major, macro_ver.minor, v,
88 ),
89 ));
90 }
91 _ => {}
92 }
93 }
94 Some(ExportMacro::AvailableIn(macro_ver)) => {
95 let since = type_doc.and_then(|d| d.since.as_ref());
96 match since {
97 None => {
98 violations.push(self.violation_at(
99 path,
100 >.location,
101 format!(
102 "Type '{}' has AVAILABLE_IN_{}_{} but is missing a Since: annotation",
103 gt.type_name,
104 macro_ver.major, macro_ver.minor,
105 ),
106 ));
107 }
108 Some(v) if v != macro_ver => {
109 violations.push(self.violation_at(
110 path,
111 >.location,
112 format!(
113 "Type '{}' has AVAILABLE_IN_{}_{} but Since: says {}",
114 gt.type_name, macro_ver.major, macro_ver.minor, v,
115 ),
116 ));
117 }
118 _ => {}
119 }
120 }
121 None if !gt.export_macros.is_empty() => {
122 if let Some(since) = type_doc.and_then(|d| d.since.as_ref()) {
123 violations.push(self.violation_at(
124 path,
125 >.location,
126 format!(
127 "Type '{}' has Since: {} but is missing a versioned export macro",
128 gt.type_name,
129 since,
130 ),
131 ));
132 }
133 }
134 _ => {}
135 }
136 }
137 }
138 }
139
140 fn check_functions_since(&self, ast_context: &AstContext, violations: &mut Vec<Violation>) {
141 for (path, file) in ast_context.iter_header_files() {
142 if !ast_context.is_public_header(path).unwrap_or(false) {
143 continue;
144 }
145
146 for func_decl in file.iter_function_declarations() {
147 if func_decl.name.ends_with("_get_type") || func_decl.name.ends_with("_error_quark")
148 {
149 continue;
150 }
151
152 let func_doc = ast_context.find_func_doc(&func_decl.name);
153
154 match func_decl
155 .export_macros
156 .iter()
157 .find(|m| m.version().is_some())
158 {
159 Some(
160 ExportMacro::DeprecatedIn(macro_ver)
161 | ExportMacro::DeprecatedInFor(macro_ver, _),
162 ) => {
163 let dep_ver = func_doc.and_then(|d| d.deprecated.as_ref().map(|(v, _)| v));
164 match dep_ver {
165 None => {
166 violations.push(self.violation_at(
167 path,
168 &func_decl.location,
169 format!(
170 "Function '{}' has DEPRECATED_IN_{}_{} but is missing a Deprecated: annotation",
171 func_decl.name,
172 macro_ver.major, macro_ver.minor,
173 ),
174 ));
175 }
176 Some(v) if v != macro_ver => {
177 violations.push(self.violation_at(
178 path,
179 &func_decl.location,
180 format!(
181 "Function '{}' has DEPRECATED_IN_{}_{} but Deprecated: says {}",
182 func_decl.name,
183 macro_ver.major, macro_ver.minor, v,
184 ),
185 ));
186 }
187 _ => {}
188 }
189 }
190 Some(ExportMacro::AvailableIn(macro_ver)) => {
191 let since = func_doc.and_then(|d| d.since.as_ref());
192
193 let parent_type_ver = file
194 .iter_all_gobject_types()
195 .find(|gt| func_decl.name.starts_with(>.function_prefix))
196 .and_then(|gt| gt.export_macros.iter().find_map(|m| m.version()))
197 .or_else(|| {
198 file.iter_function_declarations()
199 .filter(|d| d.name.ends_with("_get_type"))
200 .find(|d| {
201 let prefix = &d.name[..d.name.len() - "_get_type".len()];
202 func_decl.name.starts_with(prefix)
203 })
204 .and_then(|d| d.export_macros.iter().find_map(|m| m.version()))
205 });
206
207 if parent_type_ver.is_some_and(|p| p == macro_ver) {
208 continue;
209 }
210
211 match since {
212 None => {
213 violations.push(self.violation_at(
214 path,
215 &func_decl.location,
216 format!(
217 "Function '{}' has AVAILABLE_IN_{}_{} but is missing a Since: annotation",
218 func_decl.name,
219 macro_ver.major, macro_ver.minor,
220 ),
221 ));
222 }
223 Some(v) if v != macro_ver => {
224 violations.push(self.violation_at(
225 path,
226 &func_decl.location,
227 format!(
228 "Function '{}' has AVAILABLE_IN_{}_{} but Since: says {}",
229 func_decl.name, macro_ver.major, macro_ver.minor, v,
230 ),
231 ));
232 }
233 _ => {}
234 }
235 }
236 None if !func_decl.export_macros.is_empty() => {
237 if let Some(since) = func_doc.and_then(|d| d.since.as_ref()) {
238 violations.push(self.violation_at(
239 path,
240 &func_decl.location,
241 format!(
242 "Function '{}' has Since: {} but is missing a versioned export macro",
243 func_decl.name,
244 since,
245 ),
246 ));
247 }
248 }
249 _ => {}
250 }
251 }
252 }
253 }
254
255 fn check_property_since_consistency(
256 &self,
257 ast_context: &AstContext,
258 violations: &mut Vec<Violation>,
259 ) {
260 for (path, file) in ast_context.iter_all_files() {
261 for gt in file.iter_all_gobject_types() {
262 for prop_assignment in >.properties {
263 let property = prop_assignment.property();
264 let prop_name = &property.name;
265 let prop_since = property.doc.as_ref().and_then(|d| d.since.as_ref());
266
267 let mut getter_setter_names: Vec<(String, &str)> = Vec::new();
268
269 if let Some(doc) = &property.doc {
270 let prefix = >.function_prefix;
271 for ann in &doc.annotations {
272 match ann {
273 PropertyAnnotation::Getter(short) => {
274 getter_setter_names
275 .push((format!("{prefix}_{short}"), "getter"));
276 }
277 PropertyAnnotation::Setter(short) => {
278 getter_setter_names
279 .push((format!("{prefix}_{short}"), "setter"));
280 }
281 _ => {}
282 }
283 }
284 }
285
286 for (_, decl_file) in ast_context.iter_header_files() {
287 for func_decl in decl_file.iter_function_declarations() {
288 if let Some(doc) = &func_decl.doc {
289 for ann in &doc.annotations {
290 match ann {
291 FunctionAnnotation::GetProperty(p)
292 if p == prop_name
293 && !getter_setter_names
294 .iter()
295 .any(|(n, _)| n == &func_decl.name) =>
296 {
297 getter_setter_names
298 .push((func_decl.name.clone(), "getter"));
299 }
300 FunctionAnnotation::SetProperty(p)
301 if p == prop_name
302 && !getter_setter_names
303 .iter()
304 .any(|(n, _)| n == &func_decl.name) =>
305 {
306 getter_setter_names
307 .push((func_decl.name.clone(), "setter"));
308 }
309 _ => {}
310 }
311 }
312 }
313 }
314 }
315
316 for (func_name, role) in &getter_setter_names {
317 let func_since = ast_context
318 .find_func_doc(func_name)
319 .and_then(|d| d.since.as_ref());
320
321 match (prop_since, func_since) {
322 (Some(pv), None) => {
323 violations.push(self.violation_at(
324 path,
325 >.location,
326 format!(
327 "Property '{prop_name}' has Since: {pv} but \
328 {role} '{func_name}' has no Since: annotation"
329 ),
330 ));
331 }
332 (None, Some(fv)) => {
333 violations.push(self.violation_at(
334 path,
335 >.location,
336 format!(
337 "Property '{prop_name}' has no Since: annotation but \
338 {role} '{func_name}' has Since: {fv}"
339 ),
340 ));
341 }
342 (Some(pv), Some(fv)) if pv != fv => {
343 violations.push(self.violation_at(
344 path,
345 >.location,
346 format!(
347 "Property '{prop_name}' Since: {pv} does not match \
348 {role} '{func_name}' Since: {fv}"
349 ),
350 ));
351 }
352 _ => {}
353 }
354 }
355 }
356 }
357 }
358 }
359
360 fn check_enum_value_inline_since(
361 &self,
362 ast_context: &AstContext,
363 violations: &mut Vec<Violation>,
364 ) {
365 for (path, file) in ast_context.iter_header_files() {
366 if !ast_context.is_public_header(path).unwrap_or(false) {
367 continue;
368 }
369
370 for enum_info in file.iter_all_enums() {
371 for value in &enum_info.values {
372 let Some(doc) = &value.doc else {
373 continue;
374 };
375 if doc.since.is_some() {
376 continue;
377 }
378 let has_inline_since =
379 doc.description.iter().any(|line| line.contains("Since:"));
380 if has_inline_since {
381 violations.push(self.violation_at(
382 path,
383 &value.name_location,
384 format!(
385 "Enum member '{}' has Since: in inline doc which g-ir-scanner cannot detect — use a standalone /** {}: doc block",
386 value.name, value.name,
387 ),
388 ));
389 }
390 }
391 }
392 }
393 }
394}