omnicoach 0.3.0

Lightweight, high-signal static analysis and command linter for Python and Rust
//! Rule targeting banned abbreviations in definitions across multiple languages.

use crate::code_lint::ast::ParsedFile;
use crate::code_lint::contract::{CodeRule, RuleTarget};
use crate::diagnostic::{Diagnostic, Language, RuleName, ViolationTemplate, violation_template};
use crate::rule_declaration::{
    Classification, Consensus, Declaration, Example, FilterListDefaults, ImpactedQuality, ListKind,
    ListOption, Precision, Reference, RuleDoc, RuleOptions, Topic,
};
use std::collections::HashSet;
use std::path::Path;

const BANNED: ListOption = ListOption {
    kind: ListKind::Deny,
    doc: "Abbreviations flagged as a word of an identifier.",
    default: FilterListDefaults {
        base: &[
            "err", "ctx", "cfg", "res", "msg", "num", "btn", "cb", "ch", "diag", "ty", "cat",
            "stmt", "ext", "fmt", "arch", "vis",
        ],
        extend: &[],
        remove: &[],
    },
};

const TEMPLATE: ViolationTemplate = violation_template! {
    summary: "Name `{name}` contains the abbreviation `{token}`.",
    rationale: "An ambiguous shorthand forces the reader to guess the word it stands for and fragments searches across the codebase.",
    suggestion: "Rename `{name}` to spell out `{token}` in full.",
};

/// The rule's declaration.
pub const RULE: CodeRule<ListOption> = CodeRule {
    declaration: Declaration {
        name: RuleName("abbreviated-name"),
        template: &TEMPLATE,
        languages: &[Language::Python, Language::Rust],
        options: RuleOptions::code_rule(BANNED),
        classification: Classification {
            topics: &[Topic::ABBREVIATED_NAMES],
            precision: Precision::Heuristic,
            consensus: Consensus::Opinionated,
            impacted_quality: ImpactedQuality::Maintainability,
        },
        doc: RuleDoc {
            summary: "Flags names that contain an abbreviation such as `ctx` or `msg`.",
            what_it_does: indoc::indoc! {r"
                Flags a name the code defines when one of its words is a banned abbreviation,
                such as `handle_msg` or `TaskRes`. Only whole words match, so `strategy` and
                `category` are not flagged. Import aliases such as `import numpy as np`,
                abbreviations inherited from an imported symbol in the file (such as `ext_bot`
                when `ExtBot` is imported), and names imposed by a contract (Python `@override`
                methods, members of a Rust `impl Trait for Type` block) are skipped, but the
                parameters of those methods are still checked."},
            why_is_this_bad: indoc::indoc! {r"
                An abbreviation makes the reader guess: `res` can be a result, a response or a
                resource, `ch` a channel or a character. Different authors also shorten the same
                word differently (`cfg`, `conf`, `config`), so a search for one spelling misses the
                others.

                Spell the word out (`context`, `message`, `result`, `config`)."},
            known_problems: None,
            references: &[Reference {
                title: "Google Python Style Guide: Naming",
                url: "https://google.github.io/styleguide/pyguide.html#316-naming",
            }],
            examples: &[
                Example {
                    language: Language::Python,
                    flagged: indoc::indoc! {r"
                        def send_alert(notifier, msg):
                            notifier.publish(msg)
                    "},
                    flagged_span: "msg",
                    fixed: indoc::indoc! {r"
                        def send_alert(notifier, message):
                            notifier.publish(message)
                    "},
                },
                Example {
                    language: Language::Rust,
                    flagged: indoc::indoc! {r"
                        fn send_alert(notifier: &Notifier, msg: &str) {
                            notifier.publish(msg);
                        }
                    "},
                    flagged_span: "msg",
                    fixed: indoc::indoc! {r"
                        fn send_alert(notifier: &Notifier, message: &str) {
                            notifier.publish(message);
                        }
                    "},
                },
            ],
        },
    },
    target: RuleTarget::All,
    check: check_file,
};

fn check_file(
    rule: &CodeRule<ListOption>,
    path: &Path,
    file: &ParsedFile,
    banned: &HashSet<String>,
) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();
    let imported_segments: Vec<Vec<String>> =
        crate::code_lint::semantic::bindings::collect_imported_names(file)
            .into_iter()
            .map(|name| split_segments(&name))
            .filter(|segments| !segments.is_empty())
            .collect();

    for node in crate::code_lint::semantic::bindings::collect_renameable_bindings(file) {
        let name = node.text();
        let segments = split_segments(&name);
        for segment in non_imported_segments(&segments, &imported_segments) {
            if banned.contains(segment) {
                diagnostics.push(rule.diagnostic_at_node(
                    path,
                    &node,
                    &[("name", &name), ("token", segment)],
                ));
                // Flag each node at most once
                break;
            }
        }
    }

    diagnostics
}

/// Returns the word segments of `segments` that do not belong to a contiguous match of any
/// imported symbol's segment sequence.
fn non_imported_segments<'a>(
    segments: &'a [String],
    imported_segments: &[Vec<String>],
) -> Vec<&'a str> {
    segments
        .iter()
        .enumerate()
        .filter(|&(index, _)| {
            !imported_segments.iter().any(|imported| {
                !imported.is_empty()
                    && segments
                        .windows(imported.len())
                        .enumerate()
                        .any(|(start, window)| {
                            window == imported.as_slice()
                                && index >= start
                                && index < start + imported.len()
                        })
            })
        })
        .map(|(_, segment)| segment.as_str())
        .collect()
}

/// Helper to split identifiers into sub-word segments.
fn split_segments(name: &str) -> Vec<String> {
    let mut segments = Vec::new();
    let mut current = String::new();
    let mut previous_char: Option<char> = None;

    for character in name.chars() {
        if character == '_' {
            if !current.is_empty() {
                segments.push(current.to_lowercase());
                current.clear();
            }
            previous_char = None;
            continue;
        }

        // Split at lowercase/digit -> uppercase transition
        if let Some(prev) = previous_char
            && character.is_uppercase()
            && (prev.is_lowercase() || prev.is_numeric())
            && !current.is_empty()
        {
            segments.push(current.to_lowercase());
            current.clear();
        }

        current.push(character);
        previous_char = Some(character);
    }

    if !current.is_empty() {
        segments.push(current.to_lowercase());
    }

    segments
}

#[cfg(test)]
crate::test_utils::rule_test!(
    RULE,
    {
        Python => {
            pass: [
                full_domain_words_allowed => r#"
                    def handle_message(context, error_message):
                        manager = "active"
                        configuration = 42
                "#,
                substring_containing_banned_token_allowed => r#"
                    def process(strategy, category):
                        pass
                "#,
                unaliased_import_statement_exempt => r#"
                    import cfg
                "#,
                unaliased_from_import_exempt => r#"
                    from os import error_msg
                "#,
                aliased_import_exempt => r#"
                    import os as os_cfg
                "#,
                imported_symbol_abbreviation_exempt => r#"
                    from telegram.ext import ExtBot

                    def notify(ext_bot: ExtBot, primary_ext_bot: ExtBot) -> None:
                        active_ext_bot = ext_bot
                "#,
                override_method_contract_exempt => r#"
                    from typing import override

                    class CustomDecoder(BaseDecoder):
                        @override
                        def from_ctx(self, data: bytes) -> None:
                            pass
                "#,
                attribute_writes_outside_declarations_not_checked => r#"
                    class Worker:
                        def update(self, items, index) -> None:
                            self.ctx = 1
                            items[index] = 2
                "#,
            ],
            fail: [
                camel_case_class_name => r#"
                    class TaskRes:
                        pass
                "# => "TaskRes",
                snake_case_function_name => r#"
                    def handle_msg():
                        pass
                "# => "handle_msg",
                parameter_name => r#"
                    def process(req_ctx):
                        pass
                "# => "req_ctx",
                typed_varargs_parameter_name => r#"
                    def process(*req_ctx: int):
                        pass
                "# => "req_ctx",
                digit_to_uppercase_split => r#"
                    def process(v2Ctx):
                        pass
                "# => "v2Ctx",
                single_diagnostic_when_multiple_tokens_banned => r#"
                    err_msg = "failed"
                "# => "err_msg",
                unrelated_abbreviation_not_exempted_by_imported_symbol => r#"
                    from telegram.ext import ExtBot

                    def notify(ext_bot: ExtBot, file_ext: str) -> None:
                        pass
                "# => "file_ext",
                unannotated_method_flagged => r#"
                    class CustomDecoder:
                        def from_ctx(self, data: bytes) -> None:
                            pass
                "# => "from_ctx",
                init_attribute_declaration => r#"
                    class Worker:
                        def __init__(self, context) -> None:
                            self.ctx = context
                            self.ctx = None
                "# => "ctx",
                class_body_attribute_declaration => r#"
                    class Worker:
                        ctx: str

                        def __init__(self, context: str) -> None:
                            self.ctx = context
                "# => "ctx",
            ],
        },
        Rust => {
            pass: [
                full_domain_words_allowed => r#"
                    fn handle_context(error_message: &str) {
                        let configuration = 1;
                    }
                "#,
                substring_containing_banned_token_allowed => r#"
                    fn process(strategy: usize, category: usize) {}
                "#,
                unaliased_imports_exempt => r#"
                    use std::fmt;
                "#,
                aliased_import_exempt => r#"
                    use std::collections::HashMap as my_cfg;
                "#,
                imported_symbol_abbreviation_exempt => r#"
                    use telegram::ExtBot;

                    fn notify(ext_bot: &ExtBot, primary_ext_bot: &ExtBot) {
                        let active_ext_bot = ext_bot;
                    }
                "#,
                trait_impl_associated_type_exempt => r#"
                    impl Decoder for Wrapper {
                        type Err = ();
                    }
                "#,
                trait_impl_method_exempt => r#"
                    impl Decoder for Wrapper {
                        fn from_ctx(&self) {}
                    }
                "#,
            ],
            fail: [
                camel_case_struct_name => r#"
                    struct MyRes;
                "# => "MyRes",
                snake_case_function_name => r#"
                    fn process_err() {}
                "# => "process_err",
                digit_to_uppercase_split => r#"
                    fn main() {
                        let v2Ctx = 2;
                    }
                "# => "v2Ctx",
                single_diagnostic_when_multiple_tokens_banned => r#"
                    fn main() {
                        let err_msg = 1;
                    }
                "# => "err_msg",
                unrelated_abbreviation_not_exempted_by_imported_symbol => r#"
                    use telegram::ExtBot;

                    fn notify(ext_bot: &ExtBot, file_ext: &str) {}
                "# => "file_ext",
                trait_impl_parameter_flagged => r#"
                    impl Decoder for Wrapper {
                        fn from_ctx(&self, msg: u8) {}
                    }
                "# => "msg",
                inherent_method_flagged => r#"
                    impl Wrapper {
                        fn from_ctx(&self) {}
                    }
                "# => "from_ctx",
                struct_field => r#"
                    struct Request {
                        ctx: Context,
                        body: String,
                    }
                "# => "ctx",
            ],
        },
    }
);