use std::collections::HashMap;
use gobject_ast::model::{
Expression, FileModel, ParamFlag, ParamSpecAssignment, Property, PropertyType, Statement,
SwitchStatement,
};
use crate::{
ast_context::AstContext,
config::Config,
rules::{ConfigOption, Fix, Rule, Violation},
};
pub struct PropertySwitchExhaustiveness;
impl Rule for PropertySwitchExhaustiveness {
fn name(&self) -> &'static str {
"property_switch_exhaustiveness"
}
fn description(&self) -> &'static str {
"Ensure get_property/set_property switch statements handle all required properties"
}
fn category(&self) -> crate::rules::Category {
crate::rules::Category::Correctness
}
fn fixable(&self) -> bool {
true
}
fn config_options(&self) -> &'static [ConfigOption] {
&[
ConfigOption {
name: "style",
option_type: "string",
default_value: "\"typed\"",
example_value: "\"legacy\"",
description: "Property enum style: \"typed\" (strict, requires enum casts and all properties in switches) or \"legacy\" (relaxed, only checks read-write properties)",
},
ConfigOption {
name: "readable_flags",
option_type: "array<string>",
default_value: "[]",
example_value: "[\"MY_LIB_READABLE\", \"MY_LIB_READWRITE\"]",
description: "Additional flag names indicating readable properties (G_PARAM_READABLE and G_PARAM_READWRITE are always included)",
},
ConfigOption {
name: "writable_flags",
option_type: "array<string>",
default_value: "[]",
example_value: "[\"MY_LIB_WRITABLE\", \"MY_LIB_READWRITE\"]",
description: "Additional flag names indicating writable properties (G_PARAM_WRITABLE and G_PARAM_READWRITE are always included)",
},
]
}
fn check_all(
&self,
ast_context: &AstContext,
config: &Config,
violations: &mut Vec<Violation>,
) {
let rule_config = &config.rules.property_switch_exhaustiveness;
let style = rule_config
.options
.get("style")
.and_then(|v| v.as_str())
.unwrap_or("typed");
let readable_flags = self.get_flag_patterns(rule_config, "readable_flags");
let writable_flags = self.get_flag_patterns(rule_config, "writable_flags");
for (_path, file) in ast_context.iter_all_files() {
for enum_info in file.iter_property_enums() {
let Some(ctx) = file.resolve_property_enum_context(enum_info) else {
continue;
};
let property_names: Vec<&str> = enum_info
.values
.iter()
.filter(|v| !v.is_prop_0() && !v.is_prop_last())
.map(|v| v.name.as_str())
.collect();
if property_names.is_empty() {
continue;
}
let property_access = self.build_property_access_map(
&ctx.gobject_type.properties,
&readable_flags,
&writable_flags,
);
if let Some(func_name) = ctx.get_property_func {
self.check_property_function(
file,
func_name,
&property_names,
&property_access,
true, style,
&config.style,
violations,
);
}
if let Some(func_name) = ctx.set_property_func {
self.check_property_function(
file,
func_name,
&property_names,
&property_access,
false, style,
&config.style,
violations,
);
}
}
}
}
}
impl PropertySwitchExhaustiveness {
fn get_flag_patterns(
&self,
rule_config: &crate::config::RuleConfig,
option_name: &str,
) -> Vec<ParamFlag> {
let mut flags = if option_name == "readable_flags" {
vec![ParamFlag::Readable, ParamFlag::ReadWrite]
} else {
vec![ParamFlag::Writable, ParamFlag::ReadWrite]
};
if let Some(extra) = rule_config
.options
.get(option_name)
.and_then(|v| v.as_array())
{
for flag in extra
.iter()
.filter_map(|v| v.as_str().map(ParamFlag::from_identifier))
{
if !flags.contains(&flag) {
flags.push(flag);
}
}
}
flags
}
fn build_property_access_map<'a>(
&self,
assignments: &'a [ParamSpecAssignment],
readable_flags: &[ParamFlag],
writable_flags: &[ParamFlag],
) -> HashMap<&'a str, Option<(bool, bool)>> {
let mut access_map = HashMap::new();
for assignment in assignments {
if let Some(enum_val) = assignment.get_installed_enum_value() {
let access =
self.get_property_access(assignment.property(), readable_flags, writable_flags);
access_map.insert(enum_val, access);
}
}
access_map
}
fn get_property_access(
&self,
property: &Property,
readable_flags: &[ParamFlag],
writable_flags: &[ParamFlag],
) -> Option<(bool, bool)> {
if matches!(property.property_type, PropertyType::Override) {
return None;
}
let has_readable = property.flags.iter().any(|f| readable_flags.contains(f));
let has_writable = property.flags.iter().any(|f| writable_flags.contains(f));
Some((has_readable, has_writable))
}
#[allow(clippy::too_many_arguments)]
fn check_property_function(
&self,
file: &FileModel,
func_name: &str,
property_names: &[&str],
property_access: &HashMap<&str, Option<(bool, bool)>>,
is_getter: bool,
style: &str,
call_style: &crate::config::Style,
violations: &mut Vec<Violation>,
) {
let func = match file
.iter_function_definitions()
.find(|f| f.name == func_name)
{
Some(f) => f,
None => return,
};
for stmt in &func.body_statements {
let switch_stmt = match stmt {
Statement::Switch(sw) => sw,
_ => continue,
};
if !self.is_property_switch(&switch_stmt.condition) {
continue;
}
let handled_cases = switch_stmt.case_identifiers();
let mut missing_properties = Vec::new();
for prop_name in property_names {
if handled_cases.contains(prop_name) {
continue;
}
if style == "legacy" {
let access = property_access.get(*prop_name).copied().flatten();
if let Some((is_readable, is_writable)) = access {
if is_getter && !is_readable && is_writable {
continue;
}
if !is_getter && is_readable && !is_writable {
continue;
}
}
}
missing_properties.push(*prop_name);
}
let mut auto_fixable_properties = Vec::new();
let mut has_non_fixable = false;
for prop_name in &missing_properties {
let access = property_access.get(*prop_name).copied().flatten();
match access {
Some((is_readable, is_writable)) => {
let should_use_assert = if style == "typed" {
if is_getter {
!is_readable && is_writable
} else {
is_readable && !is_writable
}
} else {
false
};
if should_use_assert {
auto_fixable_properties.push(*prop_name);
} else {
has_non_fixable = true;
let message = format!(
"Property '{}' should be handled in {} switch statement",
prop_name, func_name
);
violations.push(self.violation(
&file.path,
switch_stmt.location.line,
1,
message,
));
}
}
None => {
has_non_fixable = true;
let message = format!(
"Property '{}' should be handled in {} switch statement",
prop_name, func_name
);
violations.push(self.violation(
&file.path,
switch_stmt.location.line,
1,
message,
));
}
}
}
if !auto_fixable_properties.is_empty() {
let can_remove_default = style == "typed"
&& matches!(switch_stmt.condition, Expression::Cast(_))
&& switch_stmt.has_default_case()
&& !has_non_fixable;
let fix = if can_remove_default {
self.generate_replace_default_with_cases_fix(
&auto_fixable_properties,
switch_stmt,
call_style,
)
} else {
self.generate_insert_cases_fix(
&auto_fixable_properties,
switch_stmt,
call_style,
)
};
let message = if auto_fixable_properties.len() == 1 {
format!(
"Property '{}' should be handled in {} switch statement",
auto_fixable_properties[0], func_name
)
} else {
format!(
"{} properties should be handled in {} switch statement",
auto_fixable_properties.len(),
func_name
)
};
violations.push(self.violation_with_fixes(
&file.path,
switch_stmt.location.line,
1,
message,
vec![fix],
));
}
if style == "typed"
&& missing_properties.is_empty()
&& matches!(switch_stmt.condition, Expression::Cast(_))
&& switch_stmt.has_default_case()
{
let (start, end) = self.find_default_case_range(switch_stmt);
let fix = Fix::delete(start, end);
violations.push(self.violation_with_fixes(
&file.path,
switch_stmt.location.line,
1,
"Switch is exhaustive with enum cast; default case can be removed for compile-time checking".to_string(),
vec![fix],
));
}
}
}
fn is_property_switch(&self, condition: &Expression) -> bool {
match condition {
Expression::Identifier(id) => {
id.name == "prop_id" || id.name == "property_id" || id.name.ends_with("_prop_id")
}
Expression::Cast(cast) => self.is_property_switch(&cast.operand),
_ => false,
}
}
fn generate_insert_cases_fix(
&self,
prop_names: &[&str],
switch_stmt: &SwitchStatement,
call_style: &crate::config::Style,
) -> Fix {
let insertion_point = self.find_case_insertion_point(switch_stmt);
let (case_indent, body_indent) = self.detect_indentation(switch_stmt);
let assert_call = call_style.format_call_stmt("g_assert_not_reached", &[]);
let mut replacement = String::new();
for (i, prop_name) in prop_names.iter().enumerate() {
if i + 1 < prop_names.len() {
replacement.push_str(&format!("{}case {}:\n", case_indent, prop_name));
} else {
replacement.push_str(&format!(
"{}case {}:\n{}{}\n{}break;\n",
case_indent, prop_name, body_indent, assert_call, body_indent
));
}
}
Fix::new(insertion_point, insertion_point, replacement)
}
fn generate_replace_default_with_cases_fix(
&self,
prop_names: &[&str],
switch_stmt: &SwitchStatement,
call_style: &crate::config::Style,
) -> Fix {
let (case_indent, body_indent) = self.detect_indentation(switch_stmt);
let assert_call = call_style.format_call_stmt("g_assert_not_reached", &[]);
let (start, end) = self.find_default_case_range(switch_stmt);
let mut replacement = String::new();
for (i, prop_name) in prop_names.iter().enumerate() {
if i + 1 < prop_names.len() {
replacement.push_str(&format!("{}case {}:\n", case_indent, prop_name));
} else {
replacement.push_str(&format!(
"{}case {}:\n{}{}\n{}break;\n",
case_indent, prop_name, body_indent, assert_call, body_indent
));
}
}
Fix::new(start, end, replacement)
}
fn find_default_case_range(&self, switch_stmt: &SwitchStatement) -> (usize, usize) {
let default_case = switch_stmt
.cases
.iter()
.find(|c| c.label.value.is_none())
.unwrap();
let (line_start, _) = default_case.label.location.find_line_bounds();
let end_location = if let Some(last_stmt) = default_case.body.last() {
last_stmt.location()
} else {
&default_case.label.location
};
let (_, line_end) = end_location.find_line_bounds_with_following_blank();
(line_start, line_end)
}
fn find_case_insertion_point(&self, switch_stmt: &SwitchStatement) -> usize {
if let Some(default_case) = switch_stmt.default_case() {
let (line_start, _) = default_case.label.location.find_line_bounds();
return line_start;
}
let last_case = switch_stmt.cases.iter().rfind(|c| c.label.value.is_some());
if let Some(last_case) = last_case {
if let Some(last_stmt) = last_case.body.last() {
let (_, line_end) = last_stmt.location().find_line_bounds_with_following_blank();
line_end
} else {
let (_, line_end) = last_case.label.location.find_line_bounds();
line_end
}
} else {
switch_stmt.location.start_byte
}
}
fn detect_indentation(&self, switch_stmt: &SwitchStatement) -> (String, String) {
for case in &switch_stmt.cases {
if let Some(first_stmt) = case.body.first() {
let case_indent = case.label.location.extract_indentation();
let body_indent = first_stmt.location().extract_indentation();
return (case_indent, body_indent);
}
}
if let Some(case) = switch_stmt.cases.first() {
let case_indent = case.label.location.extract_indentation();
let body_indent = format!("{} ", case_indent);
return (case_indent, body_indent);
}
(" ".to_string(), " ".to_string())
}
}