Skip to main content

gobject_linter/rules/
property_switch_exhaustiveness.rs

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        // Get style configuration (default to "typed")
68        let style = rule_config
69            .options
70            .get("style")
71            .and_then(|v| v.as_str())
72            .unwrap_or("typed");
73
74        // Get custom flag patterns from config
75        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, // is_getter
108                        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, // is_getter
121                        style,
122                        &config.style,
123                        violations,
124                    );
125                }
126            }
127        }
128    }
129}
130
131impl PropertySwitchExhaustiveness {
132    /// Get flag patterns from config, extending the defaults with any extra
133    /// flags the user specified
134    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    /// Build a map of property names to their access permissions (readable,
164    /// writable) Override properties return None to indicate unknown access
165    /// type
166    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    /// Determine property access type from flags
186    /// Returns None for override properties (unknown access type), otherwise
187    /// Some((is_readable, is_writable))
188    fn get_property_access(
189        &self,
190        property: &Property,
191        readable_flags: &[ParamFlag],
192        writable_flags: &[ParamFlag],
193    ) -> Option<(bool, bool)> {
194        // Override properties: we can't determine access type, so return None
195        // The rule will still check that they appear in switch statements
196        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    /// Check a property getter or setter function for exhaustiveness
207    #[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        // Find the function definition
220        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        // Find top-level switch statements in the function body.
229        // Only direct children — nested switches (e.g. inside delegation
230        // `if` blocks) must not be checked for exhaustiveness.
231        for stmt in &func.body_statements {
232            let switch_stmt = match stmt {
233                Statement::Switch(sw) => sw,
234                _ => continue,
235            };
236
237            // Check if this switch is on prop_id or similar
238            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    /// Check if a switch condition is on a property ID variable
376    fn is_property_switch(&self, condition: &Expression) -> bool {
377        match condition {
378            // Direct: switch (prop_id)
379            Expression::Identifier(id) => {
380                id.name == "prop_id" || id.name == "property_id" || id.name.ends_with("_prop_id")
381            }
382            // Cast: switch ((MyEnum) prop_id)
383            Expression::Cast(cast) => self.is_property_switch(&cast.operand),
384            _ => false,
385        }
386    }
387
388    /// Generate a fix to insert multiple cases before default (without removing
389    /// default)
390    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    /// Generate a fix to replace default case with new cases (combined
416    /// operation)
417    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        // Find the range of the default case to replace
427        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    /// Find the range to delete for the default case (used when replacing it)
445    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        // Start from line beginning
453        let (line_start, _) = default_case.label.location.find_line_bounds();
454
455        // Find the last statement in the default case body
456        let end_location = if let Some(last_stmt) = default_case.body.last() {
457            last_stmt.location()
458        } else {
459            // No statements in default case body, just use the case label location
460            &default_case.label.location
461        };
462
463        // Use the helper to get line bounds with following blank
464        let (_, line_end) = end_location.find_line_bounds_with_following_blank();
465
466        (line_start, line_end)
467    }
468
469    /// Find the byte position where a new case should be inserted
470    fn find_case_insertion_point(&self, switch_stmt: &SwitchStatement) -> usize {
471        // If there's a default case, insert before it
472        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        // Otherwise, find the last non-default case and insert after it
478        let last_case = switch_stmt.cases.iter().rfind(|c| c.label.value.is_some());
479
480        if let Some(last_case) = last_case {
481            // Insert after the last statement in the case
482            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                // No statements in case, insert right after the case label
487                let (_, line_end) = last_case.label.location.find_line_bounds();
488                line_end
489            }
490        } else {
491            // No cases at all - insert at the beginning of the switch body
492            switch_stmt.location.start_byte
493        }
494    }
495
496    /// Detect indentation levels from existing cases
497    /// Returns (case_indent, body_indent) where body_indent is for statements
498    /// inside the case
499    fn detect_indentation(&self, switch_stmt: &SwitchStatement) -> (String, String) {
500        // Try to find a case with at least one statement in its body
501        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 no case has body statements, try to get case label indentation
510        if let Some(case) = switch_stmt.cases.first() {
511            let case_indent = case.label.location.extract_indentation();
512            // Assume body is indented 2 more spaces than case
513            let body_indent = format!("{}  ", case_indent);
514            return (case_indent, body_indent);
515        }
516
517        // Default: case at 2 spaces, body at 4 spaces
518        ("  ".to_string(), "    ".to_string())
519    }
520}