Skip to main content

gobject_linter/rules/
use_g_object_class_install_properties.rs

1use gobject_ast::model::{
2    CallExpression, EnumInfo, Expression, FileModel, FunctionDefItem, GType, ParamSpecAssignment,
3    SourceLocation, Statement,
4};
5
6use crate::{
7    ast_context::AstContext,
8    config::Config,
9    rules::{Fix, Rule, Violation},
10};
11
12pub struct UseGObjectClassInstallProperties;
13
14impl Rule for UseGObjectClassInstallProperties {
15    fn name(&self) -> &'static str {
16        "use_g_object_class_install_properties"
17    }
18
19    fn description(&self) -> &'static str {
20        "Suggest g_object_class_install_properties for multiple g_object_class_install_property calls"
21    }
22
23    fn category(&self) -> crate::rules::Category {
24        crate::rules::Category::Complexity
25    }
26
27    fn fixable(&self) -> bool {
28        true
29    }
30
31    fn min_glib_version(&self) -> Option<(u32, u32)> {
32        Some((2, 26))
33    }
34
35    fn check_enum(
36        &self,
37        ast_context: &AstContext,
38        config: &Config,
39        enum_info: &EnumInfo,
40        file: &FileModel,
41        violations: &mut Vec<Violation>,
42    ) {
43        if !enum_info.is_property_enum() {
44            return;
45        }
46
47        let Some(mut ctx) = file.resolve_property_enum_context(enum_info) else {
48            return;
49        };
50        // Prefer the Define variant which has interfaces populated
51        if ctx.gobject_type.interfaces.is_empty()
52            && let Some(define) = file
53                .iter_all_gobject_types()
54                .find(|gt| gt.type_name == ctx.gobject_type.type_name && gt.kind.is_define())
55        {
56            ctx.gobject_type = define;
57        }
58
59        let individual_properties: Vec<_> = ctx
60            .gobject_type
61            .properties
62            .iter()
63            .filter(|p| !matches!(p, ParamSpecAssignment::ArraySubscript { .. }))
64            .collect();
65        if individual_properties.is_empty() {
66            return;
67        }
68
69        let fixes = self.generate_fixes(
70            ast_context,
71            file,
72            ctx.class_init,
73            ctx.gobject_type,
74            &individual_properties,
75            enum_info,
76            &config.style,
77        );
78
79        let location = individual_properties
80            .first()
81            .map(|p| p.statement_location())
82            .unwrap();
83        let message = if fixes.is_empty() {
84            format!(
85                "Consider using g_object_class_install_properties() instead of {} individual property installation calls",
86                individual_properties.len()
87            )
88        } else {
89            format!(
90                "Use g_object_class_install_properties() instead of {} individual property installation calls",
91                individual_properties.len()
92            )
93        };
94
95        violations.push(self.violation_with_fixes_at(&file.path, location, message, fixes));
96    }
97}
98
99impl UseGObjectClassInstallProperties {
100    #[allow(clippy::too_many_arguments)]
101    fn generate_fixes(
102        &self,
103        ast_context: &AstContext,
104        file: &FileModel,
105        class_init: &FunctionDefItem,
106        gobject_type: &gobject_ast::model::GObjectType,
107        assignments: &[&ParamSpecAssignment],
108        property_enum: &EnumInfo,
109        style: &crate::config::Style,
110    ) -> Vec<Fix> {
111        let mut fixes = Vec::new();
112
113        let install_calls: Vec<&CallExpression> = assignments
114            .iter()
115            .filter_map(|a| match a {
116                ParamSpecAssignment::DirectInstall { install_call, .. } => Some(install_call),
117                ParamSpecAssignment::Variable { install_call, .. } => install_call.as_ref(),
118                _ => None,
119            })
120            .collect();
121        let override_calls: Vec<&CallExpression> = assignments
122            .iter()
123            .filter_map(|a| match a {
124                ParamSpecAssignment::OverrideProperty { call, .. } => Some(call),
125                _ => None,
126            })
127            .collect();
128
129        // Check which override properties can be resolved to an interface
130        let has_convertible_overrides = !override_calls.is_empty()
131            && override_calls.iter().any(|call| {
132                call.get_arg(2)
133                    .and_then(|a| a.location().as_str())
134                    .map(|s| s.trim_matches('"'))
135                    .and_then(|name| {
136                        ast_context
137                            .project
138                            .find_interface_for_property(gobject_type, name)
139                    })
140                    .is_some()
141            });
142        if install_calls.is_empty() && !has_convertible_overrides {
143            return fixes;
144        }
145
146        let multiple_types = file
147            .iter_all_gobject_types()
148            .filter(|gt| gt.type_name != gobject_type.type_name)
149            .any(|gt| !gt.properties.is_empty());
150
151        // Pre-collect variable-pattern assignments for lookup during fix generation
152        let param_spec_assignments: Vec<_> = assignments
153            .iter()
154            .filter_map(|a| {
155                if let ParamSpecAssignment::Variable {
156                    variable_name,
157                    statement_location,
158                    call,
159                    ..
160                } = a
161                {
162                    Some((variable_name.as_str(), statement_location, call))
163                } else {
164                    None
165                }
166            })
167            .collect();
168
169        let (n_props_name, sentinel_fixes) = self.resolve_enum_sentinel(
170            property_enum,
171            has_convertible_overrides,
172            &override_calls,
173            gobject_type,
174            multiple_types,
175        );
176        fixes.extend(sentinel_fixes);
177
178        let (array_name, array_fixes) = self.resolve_array(
179            file,
180            property_enum,
181            assignments,
182            gobject_type,
183            multiple_types,
184            &n_props_name,
185        );
186        fixes.extend(array_fixes);
187
188        let object_class_var = class_init
189            .iter_local_declarations()
190            .find(|decl| decl.type_info.base_type == "GObjectClass")
191            .map_or("object_class", |decl| decl.name.as_str());
192
193        let all_calls_for_indent = install_calls.first().or(override_calls.first()).copied();
194        let indentation = if let Some(first_call) = all_calls_for_indent {
195            if let Some(stmt) = self.find_statement_containing_call(
196                &class_init.body_statements,
197                first_call.location.start_byte,
198            ) {
199                stmt.location().extract_indentation()
200            } else {
201                "  ".to_string()
202            }
203        } else {
204            "  ".to_string()
205        };
206
207        let (call_fixes, param_spec_vars) = self.convert_install_calls(
208            &install_calls,
209            &param_spec_assignments,
210            class_init,
211            &array_name,
212            &indentation,
213            style,
214        );
215        fixes.extend(call_fixes);
216
217        fixes.extend(self.convert_override_calls(
218            &override_calls,
219            ast_context,
220            gobject_type,
221            class_init,
222            &array_name,
223            &indentation,
224            style,
225        ));
226
227        for var_name in param_spec_vars {
228            if let Some(decl) = class_init
229                .body_statements
230                .iter()
231                .flat_map(Statement::iter_declarations)
232                .find(|decl| decl.name == var_name && decl.type_info.base_type == "GParamSpec")
233            {
234                fixes.push(Fix::delete_line(&decl.location));
235            }
236        }
237
238        fixes.extend(self.ensure_install_properties_call(
239            class_init,
240            &install_calls,
241            &override_calls,
242            has_convertible_overrides,
243            object_class_var,
244            &n_props_name,
245            &array_name,
246            &indentation,
247            style,
248        ));
249
250        fixes
251    }
252
253    fn resolve_enum_sentinel(
254        &self,
255        property_enum: &EnumInfo,
256        has_convertible_overrides: bool,
257        override_calls: &[&CallExpression],
258        gobject_type: &gobject_ast::model::GObjectType,
259        multiple_types: bool,
260    ) -> (String, Vec<Fix>) {
261        let mut fixes = Vec::new();
262
263        let split_sentinel = property_enum
264            .values
265            .iter()
266            .enumerate()
267            .find_map(|(i, value)| {
268                if let Some(Expression::Identifier(id)) = &value.value_expr
269                    && let Some(sentinel) =
270                        property_enum.values[..i].iter().find(|v| v.name == id.name)
271                {
272                    Some((sentinel, value))
273                } else {
274                    None
275                }
276            });
277
278        let mut deleted_sentinel_name: Option<&str> = None;
279        if has_convertible_overrides
280            && let Some((sentinel_value, first_override_value)) = &split_sentinel
281        {
282            if first_override_value.is_prop_last() {
283                // PROP_X, N_PROPS = PROP_X — just remove the initializer
284                if let Some(value_loc) = &first_override_value.value_location {
285                    let eq_start = value_loc.find_before(b'=');
286                    fixes.push(Fix::new(eq_start, value_loc.end_byte, String::new()));
287                }
288            } else {
289                // NUM_PROPERTIES, PROP_OVERRIDE = NUM_PROPERTIES — delete sentinel
290                fixes.push(Fix::delete_line_and_trailing_blank(
291                    &sentinel_value.location,
292                ));
293                deleted_sentinel_name = Some(&sentinel_value.name);
294                if let Some(value_loc) = &first_override_value.value_location {
295                    let eq_start = value_loc.find_before(b'=');
296                    fixes.push(Fix::new(eq_start, value_loc.end_byte, String::new()));
297                }
298            }
299        }
300
301        let n_props_value =
302            property_enum.values.iter().rev().find(|v| {
303                v.is_prop_last() && deleted_sentinel_name.is_none_or(|name| v.name != name)
304            });
305
306        let n_props_mispositioned = has_convertible_overrides
307            && n_props_value.is_some_and(|nv| {
308                let nv_idx = property_enum
309                    .values
310                    .iter()
311                    .position(|v| v.name == nv.name)
312                    .unwrap();
313                override_calls.iter().any(|call| {
314                    call.get_arg(1)
315                        .and_then(|a| a.location().as_str())
316                        .and_then(|name| property_enum.values.iter().position(|v| v.name == name))
317                        .is_some_and(|idx| idx > nv_idx)
318                })
319            });
320
321        if n_props_mispositioned && let Some(nv) = n_props_value {
322            fixes.push(Fix::delete_line_and_trailing_blank(&nv.location));
323        }
324
325        let n_props_name = if let Some(n_props) = n_props_value
326            && !n_props_mispositioned
327        {
328            n_props.name.clone()
329        } else {
330            let n_props_name = n_props_value
331                .map(|v| v.name.clone())
332                .or_else(|| deleted_sentinel_name.map(std::string::ToString::to_string))
333                .unwrap_or_else(|| {
334                    let base_name = property_enum
335                        .values
336                        .first()
337                        .and_then(|v| v.name.rfind("PROP_").map(|pos| &v.name[..pos]))
338                        .map_or("N_PROPS".to_string(), |prefix| {
339                            if prefix.is_empty() {
340                                "N_PROPS".to_string()
341                            } else {
342                                format!("{}N_PROPS", prefix)
343                            }
344                        });
345                    if multiple_types && base_name == "N_PROPS" {
346                        format!("{}_N_PROPS", gobject_type.function_prefix.to_uppercase())
347                    } else {
348                        base_name
349                    }
350                });
351
352            let last_value = property_enum.values.last().unwrap();
353            let value_indentation = last_value.location.extract_indentation();
354
355            let comma_end = last_value.location.find_after(b',');
356            let (insertion_pos, needs_comma) = if comma_end > last_value.location.end_byte {
357                (comma_end, false)
358            } else {
359                (last_value.location.end_byte, true)
360            };
361
362            let n_props_decl = if needs_comma {
363                format!(",\n{}{}", value_indentation, n_props_name)
364            } else {
365                format!("\n{}{}", value_indentation, n_props_name)
366            };
367
368            fixes.push(Fix::new(insertion_pos, insertion_pos, n_props_decl));
369
370            n_props_name
371        };
372
373        (n_props_name, fixes)
374    }
375
376    fn resolve_array(
377        &self,
378        file: &FileModel,
379        property_enum: &EnumInfo,
380        assignments: &[&ParamSpecAssignment],
381        gobject_type: &gobject_ast::model::GObjectType,
382        multiple_types: bool,
383        n_props_name: &str,
384    ) -> (String, Vec<Fix>) {
385        let mut fixes = Vec::new();
386
387        let enum_member_names: Vec<&str> = property_enum
388            .values
389            .iter()
390            .map(|v| v.name.as_str())
391            .collect();
392        let existing_array_name = assignments
393            .iter()
394            .find_map(|a| {
395                if let ParamSpecAssignment::ArraySubscript { array_name, .. } = a {
396                    Some(array_name.as_str())
397                } else {
398                    None
399                }
400            })
401            .or_else(|| {
402                file.find_typed_arrays("GParamSpec", true, None)
403                    .into_iter()
404                    .find(|d| {
405                        matches!(&d.array_size, Some(Expression::Identifier(id)) if enum_member_names.contains(&id.name.as_str()))
406                    })
407                    .map(|d| d.name.as_str())
408            });
409
410        let array_name = if let Some(name) = existing_array_name {
411            name.to_string()
412        } else if multiple_types {
413            format!("{}_props", gobject_type.function_prefix)
414        } else {
415            "props".to_string()
416        };
417
418        if existing_array_name.is_some() {
419            if let Some(decl) = file
420                .find_typed_arrays("GParamSpec", true, None)
421                .into_iter()
422                .find(|d| d.name == array_name)
423                && let Some(Expression::Identifier(size_id)) = &decl.array_size
424                && size_id.name != n_props_name
425            {
426                fixes.push(Fix::new(
427                    size_id.location.start_byte,
428                    size_id.location.end_byte,
429                    n_props_name.to_string(),
430                ));
431            }
432        } else {
433            let semicolon_end = property_enum.location.find_after(b';');
434            let insertion_pos = if semicolon_end > property_enum.location.end_byte {
435                semicolon_end
436            } else {
437                property_enum.location.end_byte
438            };
439
440            let array_decl = format!(
441                "\n\nstatic GParamSpec *{}[{}] = {{ NULL, }};",
442                array_name, n_props_name
443            );
444            fixes.push(Fix::new(insertion_pos, insertion_pos, array_decl));
445        }
446
447        (array_name, fixes)
448    }
449
450    fn convert_install_calls<'a>(
451        &self,
452        install_calls: &[&'a CallExpression],
453        param_spec_assignments: &[(&str, &SourceLocation, &CallExpression)],
454        class_init: &FunctionDefItem,
455        array_name: &str,
456        indentation: &str,
457        style: &crate::config::Style,
458    ) -> (Vec<Fix>, std::collections::HashSet<&'a str>) {
459        let mut fixes = Vec::new();
460        let mut param_spec_vars = std::collections::HashSet::new();
461
462        // Convert each g_object_class_install_property call
463        for call in install_calls {
464            // Extract the property enum value (2nd argument)
465            let Some(prop_id_arg) = call.get_arg(1) else {
466                continue;
467            };
468            let Some(prop_id) = prop_id_arg.location().as_str() else {
469                continue;
470            };
471
472            // Extract the g_param_spec call (3rd argument)
473            let Some(param_spec_arg) = call.get_arg(2) else {
474                continue;
475            };
476
477            // Check if this is a variable pattern or direct call
478            let (param_spec, delete_install_call) =
479                if let Expression::Call(param_spec_call) = param_spec_arg {
480                    // Direct call: g_object_class_install_property(..., g_param_spec_xxx(...))
481                    let func_name = param_spec_call.function_name();
482                    let paren = if style.space_before_paren { " (" } else { "(" };
483                    let new_line_prefix =
484                        format!("{}[{}] = {}{}", array_name, prop_id, func_name, paren);
485                    let target_column = indentation.len() + new_line_prefix.len();
486
487                    let Some(param_spec_text) = param_spec_arg.location().as_str() else {
488                        continue;
489                    };
490                    (
491                        self.reindent_multiline(param_spec_text, target_column),
492                        false,
493                    )
494                } else {
495                    // Variable pattern: pspec = g_param_spec_xxx(...); install_property(...,
496                    // pspec);
497                    let Some(var_name) = param_spec_arg.location().as_str() else {
498                        continue;
499                    };
500
501                    let assignment = param_spec_assignments
502                        .iter()
503                        .filter(|(name, stmt_loc, _)| {
504                            *name == var_name && stmt_loc.start_byte < call.location.start_byte
505                        })
506                        .max_by_key(|(_, stmt_loc, _)| stmt_loc.start_byte);
507
508                    if let Some((_, statement_location, g_param_spec_call)) = assignment {
509                        param_spec_vars.insert(var_name);
510
511                        let func_name = g_param_spec_call.function_name();
512                        let paren = if style.space_before_paren { " (" } else { "(" };
513                        let new_line_prefix =
514                            format!("{}[{}] = {}{}", array_name, prop_id, func_name, paren);
515                        let assignment_indent = statement_location.extract_indentation();
516                        let target_column = assignment_indent.len() + new_line_prefix.len();
517
518                        let Some(param_spec_text) = g_param_spec_call.location.as_str() else {
519                            continue;
520                        };
521
522                        let replacement = format!(
523                            "{}[{}] = {};",
524                            array_name,
525                            prop_id,
526                            self.reindent_multiline(param_spec_text, target_column)
527                        );
528                        fixes.push(Fix::new(
529                            statement_location.start_byte,
530                            statement_location.find_semicolon_end(),
531                            replacement,
532                        ));
533
534                        (String::new(), true)
535                    } else {
536                        let Some(param_spec_text) = param_spec_arg.location().as_str() else {
537                            continue;
538                        };
539                        (param_spec_text.to_owned(), false)
540                    }
541                };
542
543            // Find the statement containing this install_property call
544            let Some(stmt) = self.find_statement_containing_call(
545                &class_init.body_statements,
546                call.location.start_byte,
547            ) else {
548                continue;
549            };
550
551            if delete_install_call {
552                // Delete the entire install_property call statement
553                fixes.push(Fix::delete_line(stmt.location()));
554            } else {
555                // Replace the statement with array assignment
556                let replacement = format!("{}[{}] = {};", array_name, prop_id, param_spec);
557                fixes.push(Fix::new(
558                    stmt.location().start_byte,
559                    stmt.location().find_semicolon_end(),
560                    replacement,
561                ));
562            }
563        }
564
565        (fixes, param_spec_vars)
566    }
567
568    #[allow(clippy::too_many_arguments)]
569    fn convert_override_calls(
570        &self,
571        override_calls: &[&CallExpression],
572        ast_context: &AstContext,
573        gobject_type: &gobject_ast::model::GObjectType,
574        class_init: &FunctionDefItem,
575        array_name: &str,
576        indentation: &str,
577        style: &crate::config::Style,
578    ) -> Vec<Fix> {
579        let mut fixes = Vec::new();
580
581        for call in override_calls {
582            let Some(prop_id_arg) = call.get_arg(1) else {
583                continue;
584            };
585            let Some(prop_id) = prop_id_arg.location().as_str() else {
586                continue;
587            };
588
589            let Some(prop_name_arg) = call.get_arg(2) else {
590                continue;
591            };
592            let Some(prop_name) = prop_name_arg.location().as_str() else {
593                continue;
594            };
595            let prop_name = prop_name.trim_matches('"');
596
597            let Some(stmt) = self.find_statement_containing_call(
598                &class_init.body_statements,
599                call.location.start_byte,
600            ) else {
601                continue;
602            };
603
604            let resolved = ast_context
605                .project
606                .find_interface_for_property(gobject_type, prop_name);
607            if let Some(iface_gtype) = resolved {
608                let GType::Identifier(iface_type_str) = iface_gtype else {
609                    continue;
610                };
611
612                let iface_ref =
613                    style.format_call("g_type_default_interface_ref", &[iface_type_str]);
614                let prop_name_quoted = format!("\"{}\"", prop_name);
615                let find_prop = style.format_call(
616                    "g_object_interface_find_property",
617                    &[&iface_ref, &prop_name_quoted],
618                );
619                let paren = if style.space_before_paren { " (" } else { "(" };
620                let replacement = format!(
621                    "{}[{}] = g_param_spec_override{}\"{}\",\n{}    {});",
622                    array_name, prop_id, paren, prop_name, indentation, find_prop
623                );
624                fixes.push(Fix::new(
625                    stmt.location().start_byte,
626                    stmt.location().find_semicolon_end(),
627                    replacement,
628                ));
629            }
630        }
631
632        fixes
633    }
634
635    #[allow(clippy::too_many_arguments)]
636    fn ensure_install_properties_call(
637        &self,
638        class_init: &FunctionDefItem,
639        install_calls: &[&CallExpression],
640        override_calls: &[&CallExpression],
641        has_convertible_overrides: bool,
642        object_class_var: &str,
643        n_props_name: &str,
644        array_name: &str,
645        indentation: &str,
646        style: &crate::config::Style,
647    ) -> Vec<Fix> {
648        let mut fixes = Vec::new();
649
650        let last_call = if has_convertible_overrides {
651            [
652                install_calls.last().copied(),
653                override_calls.last().copied(),
654            ]
655            .into_iter()
656            .flatten()
657            .max_by_key(|c| c.location.start_byte)
658        } else {
659            install_calls.last().copied()
660        };
661        let Some(last_call) = last_call else {
662            return fixes;
663        };
664        let Some(last_stmt) = self.find_statement_containing_call(
665            &class_init.body_statements,
666            last_call.location.start_byte,
667        ) else {
668            return fixes;
669        };
670        let last_stmt_end = last_stmt.location().find_semicolon_end();
671
672        let existing_install = class_init
673            .find_calls_matching(|name| name == "g_object_class_install_properties")
674            .into_iter()
675            .next();
676
677        if let Some(existing) = existing_install {
678            if existing.location.start_byte < last_call.location.start_byte {
679                let Some(existing_stmt) = self.find_statement_containing_call(
680                    &class_init.body_statements,
681                    existing.location.start_byte,
682                ) else {
683                    return fixes;
684                };
685                fixes.push(Fix::delete_line_and_trailing_blank(
686                    existing_stmt.location(),
687                ));
688
689                let call = style.format_call_stmt(
690                    "g_object_class_install_properties",
691                    &[object_class_var, n_props_name, array_name],
692                );
693                let install_properties_call = format!("\n\n{}{}", indentation, call);
694                fixes.push(Fix::new(
695                    last_stmt_end,
696                    last_stmt_end,
697                    install_properties_call,
698                ));
699            }
700        } else {
701            let call = style.format_call_stmt(
702                "g_object_class_install_properties",
703                &[object_class_var, n_props_name, array_name],
704            );
705            let install_properties_call = format!("\n\n{}{}", indentation, call);
706            fixes.push(Fix::new(
707                last_stmt_end,
708                last_stmt_end,
709                install_properties_call,
710            ));
711        }
712
713        fixes
714    }
715
716    fn find_statement_containing_call<'a>(
717        &self,
718        statements: &'a [Statement],
719        call_start_byte: usize,
720    ) -> Option<&'a Statement> {
721        for stmt in statements {
722            let loc = stmt.location();
723            if call_start_byte >= loc.start_byte && call_start_byte < loc.end_byte {
724                return Some(stmt);
725            }
726        }
727        None
728    }
729
730    /// Re-indent multiline text to align continuation lines to a specific
731    /// column
732    fn reindent_multiline(&self, text: &str, target_column: usize) -> String {
733        let lines: Vec<&str> = text.lines().collect();
734        if lines.len() <= 1 {
735            return text.to_string();
736        }
737
738        let continuation_indent = " ".repeat(target_column);
739
740        let mut result = String::new();
741        for (i, line) in lines.iter().enumerate() {
742            if i == 0 {
743                result.push_str(line);
744            } else {
745                result.push('\n');
746                result.push_str(&continuation_indent);
747                result.push_str(line.trim_start());
748            }
749        }
750
751        result
752    }
753}