Skip to main content

gobject_linter/
scanner.rs

1use std::{collections::HashSet, path::Path};
2
3use anyhow::Result;
4use colored::Colorize;
5use indicatif::ProgressBar;
6use rayon::prelude::*;
7use serde::Serialize;
8
9use crate::{
10    ast_context::AstContext,
11    config::{Config, RuleConfig, RuleLevel},
12    inline_ignore,
13    rules::{ConfigOption, *},
14};
15
16pub type ScanResult = Result<(Vec<Violation>, Vec<(&'static str, std::time::Duration)>)>;
17
18/// Extract a source snippet from in-memory source bytes at the given line with
19/// context
20fn get_source_snippet(source: &[u8], line: usize) -> Option<String> {
21    let content = std::str::from_utf8(source).ok()?;
22    let lines: Vec<&str> = content.lines().collect();
23
24    if line == 0 || line > lines.len() {
25        return None;
26    }
27
28    // Get 7 lines before and 3 lines after for context (11 lines total)
29    let start_line = line.saturating_sub(8); // -1 for 0-indexing, -7 for context
30    let end_line = (line + 3).min(lines.len());
31
32    let mut snippet_lines = Vec::new();
33    let mut last_was_collapsed = false;
34
35    for (i, line_text) in lines.iter().enumerate().take(end_line).skip(start_line) {
36        let trimmed = line_text.trim();
37        let is_target_line = i + 1 == line;
38
39        // Check if line is just braces/whitespace (but always show target line)
40        let is_noise = !is_target_line && matches!(trimmed, "" | "{" | "}" | "{}" | "};");
41
42        if is_noise {
43            // Collapse consecutive noise lines into ...
44            if !last_was_collapsed {
45                snippet_lines.push("...".to_string());
46                last_was_collapsed = true;
47            }
48        } else {
49            let prefix = if is_target_line { ">" } else { "" };
50            snippet_lines.push(format!("{}{}", prefix, line_text));
51            last_was_collapsed = false;
52        }
53    }
54
55    Some(snippet_lines.join("\n"))
56}
57
58/// Populate snippets for violations that don't have them
59fn populate_snippets(violations: &mut [Violation], ast_context: &AstContext) {
60    for violation in violations.iter_mut() {
61        if violation.snippet.is_none()
62            && let Some(file) = ast_context.project.files.get(&violation.file)
63        {
64            violation.snippet = get_source_snippet(&file.source, violation.line);
65        }
66    }
67}
68
69/// Filter violations in-place based on per-rule ignore patterns
70fn filter_violations_in_place(
71    violations: &mut Vec<Violation>,
72    project_root: &Path,
73    config: &Config,
74    rule_config: &RuleConfig,
75) -> Result<()> {
76    let ignore_matcher = config.build_rule_ignore_matcher(rule_config)?;
77
78    violations.retain(|v| {
79        let relative_path = v.file.strip_prefix(project_root).unwrap_or(&v.file);
80        !ignore_matcher.is_match(relative_path)
81    });
82
83    Ok(())
84}
85
86pub struct RuleEntry<'a> {
87    pub rule: Box<dyn Rule>,
88    pub level: RuleLevel,
89    pub rule_config: &'a RuleConfig,
90}
91
92/// Macro to define all rules in execution order.
93/// Format: (config_field, RuleType)
94#[macro_export]
95macro_rules! for_each_rule {
96    ($callback:ident) => {
97        $callback! {
98            (dead_code, DeadCode),
99            (unused_vfunc, UnusedVfunc),
100            (include_order, IncludeOrder),
101            (inconsistent_function_signature, InconsistentFunctionSignature),
102            (use_pragma_once, UsePragmaOnce),
103            (missing_implementation, MissingImplementation),
104            (missing_autoptr_cleanup, MissingAutoptrCleanup),
105            (missing_g_begin_decls, MissingGBeginDecls),
106            (missing_export_macro, MissingExportMacro),
107            (no_g_auto_macros, NoGAutoMacros),
108            (deprecated_add_private, DeprecatedAddPrivate),
109            (matching_declare_define, MatchingDeclareDefine),
110            (use_g_new, UseGNew),
111            (use_g_object_class_install_properties, UseGObjectClassInstallProperties),
112            (use_g_settings_typed, UseGSettingsTyped),
113            (use_g_value_set_static_string, UseGValueSetStaticString),
114            (use_g_variant_new_typed, UseGVariantNewTyped),
115            (strcmp_explicit_comparison, StrcmpExplicitComparison),
116            (type_style, TypeStyle),
117            (use_g_strcmp0, UseGStrcmp0),
118            (use_clear_functions, UseClearFunctions),
119            (use_explicit_default_flags, UseExplicitDefaultFlags),
120            (g_param_spec_null_nick_blurb, GParamSpecNullNickBlurb),
121            (g_param_spec_static_strings, GParamSpecStaticStrings),
122            (property_canonical_name, PropertyCanonicalName),
123            (g_error_init, GErrorInit),
124            (g_error_leak, GErrorLeak),
125            (g_source_id_not_stored, GSourceIdNotStored),
126            (property_enum_convention, PropertyEnumConvention),
127            (property_enum_coverage, PropertyEnumCoverage),
128            (property_switch_exhaustiveness, PropertySwitchExhaustiveness),
129            (signal_canonical_name, SignalCanonicalName),
130            (signal_enum_coverage, SignalEnumCoverage),
131            (g_object_virtual_methods_chain_up, GObjectVirtualMethodsChainUp),
132            (g_task_source_tag, GTaskSourceTag),
133            (unnecessary_null_check, UnnecessaryNullCheck),
134            (use_g_set_object, UseGSetObject),
135            (use_g_set_str, UseGSetStr),
136            (use_auto_cleanup, UseAutoCleanup),
137            (use_g_bytes_unref_to_data, UseGBytesUnrefToData),
138            (use_g_file_load_bytes, UseGFileLoadBytes),
139            (use_g_gnuc_flag_enum, UseGGnucFlagEnum),
140            (use_g_object_new_with_properties, UseGObjectNewWithProperties),
141            (use_g_object_notify_by_pspec, UseGObjectNotifyByPspec),
142            (use_g_string_free_and_steal, UseGStringFreeAndSteal),
143            (use_g_source_once, UseGSourceOnce),
144            (use_g_source_constants, UseGSourceConstants),
145            (use_g_steal_pointer, UseGStealPointer),
146            (use_g_str_has_prefix_suffix, UseGStrHasPrefixSuffix),
147            (use_g_ascii_functions, UseGAsciiFunctions),
148            (use_g_strlcpy, UseGStrlcpy),
149            (untranslated_string, UntranslatedString),
150            (gi_missing_since, GiMissingSince),
151            (gi_not_bindings_friendly, GiNotBindingsFriendly),
152        }
153    };
154}
155
156macro_rules! impl_create_all_rules {
157    ($(($config_field:ident, $rule_type:ident)),* $(,)?) => {
158        /// Create all rule instances in execution order
159        pub fn create_all_rules<'a>(config: &'a Config) -> Vec<RuleEntry<'a>> {
160            vec![
161                $({
162                    let rule = $rule_type;
163                    let min_ver = rule.min_glib_version();
164                    let opt_in = rule.opt_in();
165                    let requires_auto_cleanup = rule.requires_auto_cleanup();
166                    let level = if is_rule_compatible(config, min_ver) {
167                        let default_level = if opt_in {
168                            RuleLevel::Ignore
169                        } else {
170                            config.default_level.unwrap_or(RuleLevel::Warn)
171                        };
172                        let configured = config.rules.$config_field.level.unwrap_or(default_level);
173                        apply_msvc_compatibility(config, stringify!($config_field), requires_auto_cleanup, configured)
174                    } else {
175                        RuleLevel::Ignore
176                    };
177                    RuleEntry {
178                        rule: Box::new(rule),
179                        level,
180                        rule_config: &config.rules.$config_field,
181                    }
182                },)*
183            ]
184        }
185    };
186}
187
188/// Check if a rule is compatible with the configured minimum GLib version
189fn is_rule_compatible(config: &Config, required: Option<(u32, u32)>) -> bool {
190    let Some((req_major, req_minor)) = required else {
191        return true;
192    };
193    if let Some((major, minor)) = config.min_glib_version {
194        major > req_major || (major == req_major && minor >= req_minor)
195    } else {
196        true
197    }
198}
199
200/// Apply MSVC compatibility overrides to rule level
201fn apply_msvc_compatibility(
202    config: &Config,
203    rule_name: &str,
204    requires_auto_cleanup: bool,
205    configured_level: RuleLevel,
206) -> RuleLevel {
207    match (rule_name, config.msvc_compatible) {
208        ("no_g_auto_macros", false) => return RuleLevel::Ignore,
209        ("no_g_auto_macros", true) => return RuleLevel::Error,
210        (_, false) => return configured_level,
211        // Continue
212        (_, true) => (),
213    }
214
215    // Disable all rules that require auto cleanup attributes
216    if requires_auto_cleanup {
217        return RuleLevel::Ignore;
218    }
219
220    configured_level
221}
222
223for_each_rule!(impl_create_all_rules);
224
225macro_rules! impl_validate_config {
226    ($(($config_field:ident, $rule_type:ident)),* $(,)?) => {
227        /// Check that explicitly enabled rules are compatible with config
228        /// constraints (min_glib_version, msvc_compatible). Returns an error
229        /// describing the first conflict found.
230        pub fn validate_config(config: &Config) -> Result<()> {
231            $(
232            {
233                let rule_config = &config.rules.$config_field;
234                if let Some(level) = rule_config.level
235                    && level.is_enabled()
236                {
237                    let rule = $rule_type;
238                    let name = stringify!($config_field);
239
240                    if let Some((req_major, req_minor)) = rule.min_glib_version()
241                        && !is_rule_compatible(config, Some((req_major, req_minor)))
242                    {
243                        let (cfg_major, cfg_minor) = config.min_glib_version.unwrap_or((2, 0));
244                        anyhow::bail!(
245                            "Rule '{}' requires GLib >= {}.{}, but min_glib_version is {}.{}",
246                            name, req_major, req_minor, cfg_major, cfg_minor,
247                        );
248                    }
249
250                    if config.msvc_compatible && rule.requires_auto_cleanup() {
251                        anyhow::bail!(
252                            "Rule '{}' requires g_auto* macros, which are unavailable with msvc_compatible = true",
253                            name,
254                        );
255                    }
256
257                    if name == "no_g_auto_macros" && !config.msvc_compatible {
258                        anyhow::bail!(
259                            "Rule 'no_g_auto_macros' is only meaningful with msvc_compatible = true",
260                        );
261                    }
262                }
263            }
264            )*
265            Ok(())
266        }
267    };
268}
269
270for_each_rule!(impl_validate_config);
271
272/// Validate that all rule names in inline ignore directives are valid
273/// Returns a list of warnings about unknown rules
274fn validate_inline_ignores(
275    inline_ignores: &std::collections::HashMap<
276        &Path,
277        std::collections::HashMap<usize, Vec<String>>,
278    >,
279    rules: &[RuleEntry<'_>],
280    project_root: &Path,
281) -> Vec<String> {
282    let mut warnings = Vec::new();
283
284    // Collect all valid rule names
285    let valid_rules: HashSet<String> = rules
286        .iter()
287        .map(|entry| entry.rule.name().to_string())
288        .collect();
289
290    // Check each file's ignore directives
291    for (file_path, file_ignores) in inline_ignores {
292        for (line_num, ignored_rules) in file_ignores {
293            for rule_name in ignored_rules {
294                // Skip wildcards
295                if rule_name == "all" || rule_name == "*" {
296                    continue;
297                }
298
299                // Check if rule exists
300                if !valid_rules.contains(rule_name) {
301                    let relative_path = file_path.strip_prefix(project_root).unwrap_or(file_path);
302                    let warning = format!(
303                        "{}:{}:1: {} Unknown rule '{}' in ignore directive",
304                        relative_path.display(),
305                        line_num,
306                        "warning:".yellow(),
307                        rule_name
308                    );
309                    warnings.push(warning);
310                }
311            }
312        }
313    }
314
315    warnings
316}
317
318/// New AST-based scanner - much simpler than the old one!
319pub fn scan_with_ast(
320    ast_context: &AstContext,
321    config: &Config,
322    project_root: &Path,
323    spinner: Option<&ProgressBar>,
324    generate_snippets: bool,
325) -> ScanResult {
326    let mut violations = Vec::new();
327
328    // Parse inline ignore directives from all files
329    let inline_ignores: std::collections::HashMap<
330        &Path,
331        std::collections::HashMap<usize, Vec<String>>,
332    > = ast_context
333        .project
334        .files
335        .iter()
336        .map(|(path, file)| {
337            let ignores = inline_ignore::parse_ignore_directives(file);
338            (path.as_path(), ignores)
339        })
340        .collect();
341
342    // Register all rules in execution order
343    let rules = create_all_rules(config);
344
345    // Validate that all rule names in ignore directives are valid
346    let warnings = validate_inline_ignores(&inline_ignores, &rules, project_root);
347    for warning in warnings {
348        eprintln!("{}", warning);
349    }
350
351    // Warn about unrecognized rule config options
352    for entry in &rules {
353        let known: HashSet<&str> = entry.rule.config_options().iter().map(|o| o.name).collect();
354        for key in entry.rule_config.options.keys() {
355            if !known.contains(key.as_str()) {
356                eprintln!(
357                    "{}: unknown option '{}' for rule '{}'",
358                    "warning".yellow(),
359                    key,
360                    entry.rule.name()
361                );
362            }
363        }
364    }
365
366    if let Some(sp) = spinner {
367        sp.set_message("Running linter rules...");
368    }
369
370    // Run all rules in parallel — each gets its own violations vec
371    let per_rule: Vec<(Result<Vec<Violation>>, &str, std::time::Duration)> = rules
372        .par_iter()
373        .enumerate()
374        .map(|(rule_index, entry)| {
375            if !entry.level.is_enabled() {
376                return (Ok(Vec::new()), entry.rule.name(), std::time::Duration::ZERO);
377            }
378
379            let rule_start = std::time::Instant::now();
380            let mut rule_violations = Vec::new();
381            entry
382                .rule
383                .check_all(ast_context, config, &mut rule_violations);
384
385            for v in &mut rule_violations {
386                v.rule_index = rule_index;
387                v.level = entry.level;
388            }
389
390            if generate_snippets {
391                populate_snippets(&mut rule_violations, ast_context);
392            }
393            let filter_result = filter_violations_in_place(
394                &mut rule_violations,
395                project_root,
396                config,
397                entry.rule_config,
398            );
399            let elapsed = rule_start.elapsed();
400
401            match filter_result {
402                Ok(()) => (Ok(rule_violations), entry.rule.name(), elapsed),
403                Err(e) => (Err(e), entry.rule.name(), elapsed),
404            }
405        })
406        .collect();
407
408    let mut rule_timings: Vec<(&str, std::time::Duration)> = Vec::new();
409    for (rule_violations, name, elapsed) in per_rule {
410        if !elapsed.is_zero() {
411            rule_timings.push((name, elapsed));
412        }
413        violations.extend(rule_violations?);
414    }
415
416    // Deduplicate: keep only violations from later rules (higher index) when
417    // multiple rules fire on same line
418    deduplicate_by_rule_precedence(&mut violations);
419
420    // Filter out violations that have inline ignore directives
421    violations.retain(|v| {
422        !inline_ignore::should_ignore_violation(&v.file, v.line, v.rule, &inline_ignores)
423    });
424
425    violations.sort_by(|a, b| {
426        a.file
427            .cmp(&b.file)
428            .then(a.line.cmp(&b.line))
429            .then(a.column.cmp(&b.column))
430            .then(a.rule.cmp(b.rule))
431    });
432
433    Ok((violations, rule_timings))
434}
435
436/// List all available rules with their descriptions (text format)
437pub fn list_all_rules(config: &Config) {
438    let rules = create_all_rules(config);
439
440    let fixable_count = rules.iter().filter(|e| e.rule.fixable()).count();
441
442    println!(
443        "{} {}",
444        "Available lint rules".bold(),
445        format!("({} total, {} auto-fixable)", rules.len(), fixable_count).dimmed()
446    );
447
448    for entry in &rules {
449        let status = match entry.level {
450            RuleLevel::Error => "E".red().bold(),
451            RuleLevel::Warn => "W".yellow().bold(),
452            RuleLevel::Ignore => "-".dimmed(),
453        };
454        let name = entry.rule.name().cyan().bold();
455        let category = format!("[{}]", entry.rule.category()).magenta();
456        let desc = entry.rule.description().dimmed();
457
458        let mut tags = Vec::new();
459        if entry.rule.fixable() {
460            tags.push("[auto-fix]".yellow().to_string());
461        }
462        if entry.rule.opt_in() {
463            tags.push("[opt-in]".blue().to_string());
464        }
465        if entry.rule.requires_meson() {
466            tags.push("[meson]".blue().to_string());
467        }
468        if entry.rule.requires_auto_cleanup() {
469            tags.push("[no-msvc]".blue().to_string());
470        }
471        if let Some((major, minor)) = entry.rule.min_glib_version()
472            && (major > 2 || (major == 2 && minor > 0))
473        {
474            tags.push(format!("[glib>={major}.{minor}]").dimmed().to_string());
475        }
476        let tags_str = if tags.is_empty() {
477            String::new()
478        } else {
479            format!(" {}", tags.join(" "))
480        };
481        println!("  {} {} {}{} - {}", status, name, category, tags_str, desc);
482    }
483}
484
485/// List all available rules as JSON
486pub fn list_all_rules_json(config: &Config) -> String {
487    #[derive(Serialize)]
488    struct RuleMetadata {
489        name: String,
490        description: String,
491        #[serde(skip_serializing_if = "Option::is_none")]
492        long_description: Option<String>,
493        category: String,
494        fixable: bool,
495        opt_in: bool,
496        #[serde(skip_serializing_if = "Option::is_none")]
497        opt_in_reason: Option<String>,
498        requires_meson: bool,
499        min_glib_version: String,
500        requires_auto_cleanup: bool,
501        config_options: Vec<ConfigOption>,
502    }
503
504    #[derive(Serialize)]
505    struct RulesOutput {
506        rules: Vec<RuleMetadata>,
507        total: usize,
508        fixable_count: usize,
509    }
510
511    let rules = create_all_rules(config);
512    let fixable_count = rules.iter().filter(|e| e.rule.fixable()).count();
513
514    let metadata: Vec<RuleMetadata> = rules
515        .iter()
516        .map(|entry| {
517            // Prepend standard config options to rule-specific ones
518            let level_default = if entry.rule.opt_in() {
519                "\"ignore\""
520            } else {
521                "\"warn\""
522            };
523            let mut all_options = vec![
524                ConfigOption {
525                    name: "level",
526                    option_type: "string",
527                    default_value: level_default,
528                    example_value: "\"error\"",
529                    description: "Rule severity level: \"error\", \"warn\", or \"ignore\"",
530                },
531                ConfigOption {
532                    name: "ignore",
533                    option_type: "array<string>",
534                    default_value: "[]",
535                    example_value: "[\"tests/**\", \"examples/*.c\"]",
536                    description: "Glob patterns for files to ignore for this rule",
537                },
538            ];
539            all_options.extend_from_slice(entry.rule.config_options());
540
541            RuleMetadata {
542                name: entry.rule.name().to_string(),
543                description: entry.rule.description().to_string(),
544                long_description: entry
545                    .rule
546                    .long_description()
547                    .map(std::string::ToString::to_string),
548                category: entry.rule.category().as_str().to_string(),
549                fixable: entry.rule.fixable(),
550                opt_in: entry.rule.opt_in(),
551                opt_in_reason: entry.rule.opt_in_reason().map(String::from),
552                requires_meson: entry.rule.requires_meson(),
553                min_glib_version: entry
554                    .rule
555                    .min_glib_version()
556                    .map_or_else(|| "2.0".to_string(), |(maj, min)| format!("{maj}.{min}")),
557                requires_auto_cleanup: entry.rule.requires_auto_cleanup(),
558                config_options: all_options,
559            }
560        })
561        .collect();
562
563    let output = RulesOutput {
564        total: rules.len(),
565        fixable_count,
566        rules: metadata,
567    };
568
569    serde_json::to_string_pretty(&output).unwrap()
570}
571
572/// Keep only the violation with the highest rule_index for each (file, line,
573/// column) position
574fn deduplicate_by_rule_precedence(violations: &mut Vec<Violation>) {
575    if violations.len() <= 1 {
576        return;
577    }
578
579    // Sort by (file, line, column) so duplicates are adjacent, then by
580    // rule_index descending so the best candidate comes first in each group
581    violations.sort_by(|a, b| {
582        a.file
583            .cmp(&b.file)
584            .then(a.line.cmp(&b.line))
585            .then(a.column.cmp(&b.column))
586            .then(b.rule_index.cmp(&a.rule_index))
587    });
588
589    // Walk linearly: keep the first of each (file, line, column) group
590    // (highest rule_index due to sort order)
591    violations.dedup_by(|b, a| a.file == b.file && a.line == b.line && a.column == b.column);
592}