1use std::collections::HashMap;
2
3use gobject_ast::model::{
4 Expression, FileModel, ParamFlag, ParamSpecAssignment, Property, PropertyType, Statement,
5 SwitchStatement,
6};
7
8use crate::{
9 ast_context::AstContext,
10 config::Config,
11 rules::{ConfigOption, Fix, Rule, Violation},
12};
13
14pub struct PropertySwitchExhaustiveness;
15
16impl Rule for PropertySwitchExhaustiveness {
17 fn name(&self) -> &'static str {
18 "property_switch_exhaustiveness"
19 }
20
21 fn description(&self) -> &'static str {
22 "Ensure get_property/set_property switch statements handle all required properties"
23 }
24
25 fn category(&self) -> crate::rules::Category {
26 crate::rules::Category::Correctness
27 }
28
29 fn fixable(&self) -> bool {
30 true
31 }
32
33 fn config_options(&self) -> &'static [ConfigOption] {
34 &[
35 ConfigOption {
36 name: "style",
37 option_type: "string",
38 default_value: "\"typed\"",
39 example_value: "\"legacy\"",
40 description: "Property enum style: \"typed\" (strict, requires enum casts and all properties in switches) or \"legacy\" (relaxed, only checks read-write properties)",
41 },
42 ConfigOption {
43 name: "readable_flags",
44 option_type: "array<string>",
45 default_value: "[]",
46 example_value: "[\"MY_LIB_READABLE\", \"MY_LIB_READWRITE\"]",
47 description: "Additional flag names indicating readable properties (G_PARAM_READABLE and G_PARAM_READWRITE are always included)",
48 },
49 ConfigOption {
50 name: "writable_flags",
51 option_type: "array<string>",
52 default_value: "[]",
53 example_value: "[\"MY_LIB_WRITABLE\", \"MY_LIB_READWRITE\"]",
54 description: "Additional flag names indicating writable properties (G_PARAM_WRITABLE and G_PARAM_READWRITE are always included)",
55 },
56 ]
57 }
58
59 fn check_all(
60 &self,
61 ast_context: &AstContext,
62 config: &Config,
63 violations: &mut Vec<Violation>,
64 ) {
65 let rule_config = &config.rules.property_switch_exhaustiveness;
66
67 let style = rule_config
69 .options
70 .get("style")
71 .and_then(|v| v.as_str())
72 .unwrap_or("typed");
73
74 let readable_flags = self.get_flag_patterns(rule_config, "readable_flags");
76 let writable_flags = self.get_flag_patterns(rule_config, "writable_flags");
77
78 for (_path, file) in ast_context.iter_all_files() {
79 for enum_info in file.iter_property_enums() {
80 let Some(ctx) = file.resolve_property_enum_context(enum_info) else {
81 continue;
82 };
83
84 let property_names: Vec<&str> = enum_info
85 .values
86 .iter()
87 .filter(|v| !v.is_prop_0() && !v.is_prop_last())
88 .map(|v| v.name.as_str())
89 .collect();
90
91 if property_names.is_empty() {
92 continue;
93 }
94
95 let property_access = self.build_property_access_map(
96 &ctx.gobject_type.properties,
97 &readable_flags,
98 &writable_flags,
99 );
100
101 if let Some(func_name) = ctx.get_property_func {
102 self.check_property_function(
103 file,
104 func_name,
105 &property_names,
106 &property_access,
107 true, style,
109 &config.style,
110 violations,
111 );
112 }
113
114 if let Some(func_name) = ctx.set_property_func {
115 self.check_property_function(
116 file,
117 func_name,
118 &property_names,
119 &property_access,
120 false, style,
122 &config.style,
123 violations,
124 );
125 }
126 }
127 }
128 }
129}
130
131impl PropertySwitchExhaustiveness {
132 fn get_flag_patterns(
135 &self,
136 rule_config: &crate::config::RuleConfig,
137 option_name: &str,
138 ) -> Vec<ParamFlag> {
139 let mut flags = if option_name == "readable_flags" {
140 vec![ParamFlag::Readable, ParamFlag::ReadWrite]
141 } else {
142 vec![ParamFlag::Writable, ParamFlag::ReadWrite]
143 };
144
145 if let Some(extra) = rule_config
146 .options
147 .get(option_name)
148 .and_then(|v| v.as_array())
149 {
150 for flag in extra
151 .iter()
152 .filter_map(|v| v.as_str().map(ParamFlag::from_identifier))
153 {
154 if !flags.contains(&flag) {
155 flags.push(flag);
156 }
157 }
158 }
159
160 flags
161 }
162
163 fn build_property_access_map<'a>(
167 &self,
168 assignments: &'a [ParamSpecAssignment],
169 readable_flags: &[ParamFlag],
170 writable_flags: &[ParamFlag],
171 ) -> HashMap<&'a str, Option<(bool, bool)>> {
172 let mut access_map = HashMap::new();
173
174 for assignment in assignments {
175 if let Some(enum_val) = assignment.get_installed_enum_value() {
176 let access =
177 self.get_property_access(assignment.property(), readable_flags, writable_flags);
178 access_map.insert(enum_val, access);
179 }
180 }
181
182 access_map
183 }
184
185 fn get_property_access(
189 &self,
190 property: &Property,
191 readable_flags: &[ParamFlag],
192 writable_flags: &[ParamFlag],
193 ) -> Option<(bool, bool)> {
194 if matches!(property.property_type, PropertyType::Override) {
197 return None;
198 }
199
200 let has_readable = property.flags.iter().any(|f| readable_flags.contains(f));
201 let has_writable = property.flags.iter().any(|f| writable_flags.contains(f));
202
203 Some((has_readable, has_writable))
204 }
205
206 #[allow(clippy::too_many_arguments)]
208 fn check_property_function(
209 &self,
210 file: &FileModel,
211 func_name: &str,
212 property_names: &[&str],
213 property_access: &HashMap<&str, Option<(bool, bool)>>,
214 is_getter: bool,
215 style: &str,
216 call_style: &crate::config::Style,
217 violations: &mut Vec<Violation>,
218 ) {
219 let func = match file
221 .iter_function_definitions()
222 .find(|f| f.name == func_name)
223 {
224 Some(f) => f,
225 None => return,
226 };
227
228 for stmt in &func.body_statements {
232 let switch_stmt = match stmt {
233 Statement::Switch(sw) => sw,
234 _ => continue,
235 };
236
237 if !self.is_property_switch(&switch_stmt.condition) {
239 continue;
240 }
241
242 let handled_cases = switch_stmt.case_identifiers();
243
244 let mut missing_properties = Vec::new();
245
246 for prop_name in property_names {
247 if handled_cases.contains(prop_name) {
248 continue;
249 }
250
251 if style == "legacy" {
252 let access = property_access.get(*prop_name).copied().flatten();
253 if let Some((is_readable, is_writable)) = access {
254 if is_getter && !is_readable && is_writable {
255 continue;
256 }
257 if !is_getter && is_readable && !is_writable {
258 continue;
259 }
260 }
261 }
262
263 missing_properties.push(*prop_name);
264 }
265
266 let mut auto_fixable_properties = Vec::new();
267 let mut has_non_fixable = false;
268
269 for prop_name in &missing_properties {
270 let access = property_access.get(*prop_name).copied().flatten();
271
272 match access {
273 Some((is_readable, is_writable)) => {
274 let should_use_assert = if style == "typed" {
275 if is_getter {
276 !is_readable && is_writable
277 } else {
278 is_readable && !is_writable
279 }
280 } else {
281 false
282 };
283
284 if should_use_assert {
285 auto_fixable_properties.push(*prop_name);
286 } else {
287 has_non_fixable = true;
288 let message = format!(
289 "Property '{}' should be handled in {} switch statement",
290 prop_name, func_name
291 );
292 violations.push(self.violation(
293 &file.path,
294 switch_stmt.location.line,
295 1,
296 message,
297 ));
298 }
299 }
300 None => {
301 has_non_fixable = true;
302 let message = format!(
303 "Property '{}' should be handled in {} switch statement",
304 prop_name, func_name
305 );
306 violations.push(self.violation(
307 &file.path,
308 switch_stmt.location.line,
309 1,
310 message,
311 ));
312 }
313 }
314 }
315
316 if !auto_fixable_properties.is_empty() {
317 let can_remove_default = style == "typed"
318 && matches!(switch_stmt.condition, Expression::Cast(_))
319 && switch_stmt.has_default_case()
320 && !has_non_fixable;
321
322 let fix = if can_remove_default {
323 self.generate_replace_default_with_cases_fix(
324 &auto_fixable_properties,
325 switch_stmt,
326 call_style,
327 )
328 } else {
329 self.generate_insert_cases_fix(
330 &auto_fixable_properties,
331 switch_stmt,
332 call_style,
333 )
334 };
335
336 let message = if auto_fixable_properties.len() == 1 {
337 format!(
338 "Property '{}' should be handled in {} switch statement",
339 auto_fixable_properties[0], func_name
340 )
341 } else {
342 format!(
343 "{} properties should be handled in {} switch statement",
344 auto_fixable_properties.len(),
345 func_name
346 )
347 };
348 violations.push(self.violation_with_fixes(
349 &file.path,
350 switch_stmt.location.line,
351 1,
352 message,
353 vec![fix],
354 ));
355 }
356
357 if style == "typed"
358 && missing_properties.is_empty()
359 && matches!(switch_stmt.condition, Expression::Cast(_))
360 && switch_stmt.has_default_case()
361 {
362 let (start, end) = self.find_default_case_range(switch_stmt);
363 let fix = Fix::delete(start, end);
364 violations.push(self.violation_with_fixes(
365 &file.path,
366 switch_stmt.location.line,
367 1,
368 "Switch is exhaustive with enum cast; default case can be removed for compile-time checking".to_string(),
369 vec![fix],
370 ));
371 }
372 }
373 }
374
375 fn is_property_switch(&self, condition: &Expression) -> bool {
377 match condition {
378 Expression::Identifier(id) => {
380 id.name == "prop_id" || id.name == "property_id" || id.name.ends_with("_prop_id")
381 }
382 Expression::Cast(cast) => self.is_property_switch(&cast.operand),
384 _ => false,
385 }
386 }
387
388 fn generate_insert_cases_fix(
391 &self,
392 prop_names: &[&str],
393 switch_stmt: &SwitchStatement,
394 call_style: &crate::config::Style,
395 ) -> Fix {
396 let insertion_point = self.find_case_insertion_point(switch_stmt);
397 let (case_indent, body_indent) = self.detect_indentation(switch_stmt);
398 let assert_call = call_style.format_call_stmt("g_assert_not_reached", &[]);
399
400 let mut replacement = String::new();
401 for (i, prop_name) in prop_names.iter().enumerate() {
402 if i + 1 < prop_names.len() {
403 replacement.push_str(&format!("{}case {}:\n", case_indent, prop_name));
404 } else {
405 replacement.push_str(&format!(
406 "{}case {}:\n{}{}\n{}break;\n",
407 case_indent, prop_name, body_indent, assert_call, body_indent
408 ));
409 }
410 }
411
412 Fix::new(insertion_point, insertion_point, replacement)
413 }
414
415 fn generate_replace_default_with_cases_fix(
418 &self,
419 prop_names: &[&str],
420 switch_stmt: &SwitchStatement,
421 call_style: &crate::config::Style,
422 ) -> Fix {
423 let (case_indent, body_indent) = self.detect_indentation(switch_stmt);
424 let assert_call = call_style.format_call_stmt("g_assert_not_reached", &[]);
425
426 let (start, end) = self.find_default_case_range(switch_stmt);
428
429 let mut replacement = String::new();
430 for (i, prop_name) in prop_names.iter().enumerate() {
431 if i + 1 < prop_names.len() {
432 replacement.push_str(&format!("{}case {}:\n", case_indent, prop_name));
433 } else {
434 replacement.push_str(&format!(
435 "{}case {}:\n{}{}\n{}break;\n",
436 case_indent, prop_name, body_indent, assert_call, body_indent
437 ));
438 }
439 }
440
441 Fix::new(start, end, replacement)
442 }
443
444 fn find_default_case_range(&self, switch_stmt: &SwitchStatement) -> (usize, usize) {
446 let default_case = switch_stmt
447 .cases
448 .iter()
449 .find(|c| c.label.value.is_none())
450 .unwrap();
451
452 let (line_start, _) = default_case.label.location.find_line_bounds();
454
455 let end_location = if let Some(last_stmt) = default_case.body.last() {
457 last_stmt.location()
458 } else {
459 &default_case.label.location
461 };
462
463 let (_, line_end) = end_location.find_line_bounds_with_following_blank();
465
466 (line_start, line_end)
467 }
468
469 fn find_case_insertion_point(&self, switch_stmt: &SwitchStatement) -> usize {
471 if let Some(default_case) = switch_stmt.default_case() {
473 let (line_start, _) = default_case.label.location.find_line_bounds();
474 return line_start;
475 }
476
477 let last_case = switch_stmt.cases.iter().rfind(|c| c.label.value.is_some());
479
480 if let Some(last_case) = last_case {
481 if let Some(last_stmt) = last_case.body.last() {
483 let (_, line_end) = last_stmt.location().find_line_bounds_with_following_blank();
484 line_end
485 } else {
486 let (_, line_end) = last_case.label.location.find_line_bounds();
488 line_end
489 }
490 } else {
491 switch_stmt.location.start_byte
493 }
494 }
495
496 fn detect_indentation(&self, switch_stmt: &SwitchStatement) -> (String, String) {
500 for case in &switch_stmt.cases {
502 if let Some(first_stmt) = case.body.first() {
503 let case_indent = case.label.location.extract_indentation();
504 let body_indent = first_stmt.location().extract_indentation();
505 return (case_indent, body_indent);
506 }
507 }
508
509 if let Some(case) = switch_stmt.cases.first() {
511 let case_indent = case.label.location.extract_indentation();
512 let body_indent = format!("{} ", case_indent);
514 return (case_indent, body_indent);
515 }
516
517 (" ".to_string(), " ".to_string())
519 }
520}