Skip to main content

gobject_linter/rules/
untranslated_string.rs

1use gobject_ast::model::{Expression, FileModel, FunctionDefItem};
2
3use crate::{
4    ast_context::AstContext,
5    config::Config,
6    rules::{Category, Rule, Violation},
7};
8
9pub struct UntranslatedString;
10
11impl Rule for UntranslatedString {
12    fn name(&self) -> &'static str {
13        "untranslated_string"
14    }
15
16    fn description(&self) -> &'static str {
17        "Detect user-visible strings that should be wrapped with gettext"
18    }
19
20    fn category(&self) -> Category {
21        Category::Pedantic
22    }
23
24    fn check_func_impl(
25        &self,
26        _ast_context: &AstContext,
27        _config: &Config,
28        func: &FunctionDefItem,
29        file: &FileModel,
30        violations: &mut Vec<Violation>,
31    ) {
32        for call in func.find_calls_matching(|name| self.get_translatable_param(name).is_some()) {
33            let Some(func_name) = call.function_name_str() else {
34                continue;
35            };
36            let Some(arg_index) = self.get_translatable_param(func_name) else {
37                continue;
38            };
39            let Some(arg) = call.arguments.get(arg_index) else {
40                continue;
41            };
42            self.check_argument(arg, func_name, file, violations);
43        }
44    }
45}
46
47impl UntranslatedString {
48    fn check_argument(
49        &self,
50        arg: &Expression,
51        func_name: &str,
52        file: &FileModel,
53        violations: &mut Vec<Violation>,
54    ) {
55        if let Expression::Call(call) = arg
56            && let Some(name) = call.function_name_str()
57            && matches!(name, "_" | "gettext" | "N_" | "g_dgettext" | "g_dpgettext2")
58        {
59            return;
60        }
61
62        // Check if it's a raw string literal
63        if let Expression::StringLiteral(_) = arg {
64            // Extract the string value without quotes
65            if let Some(string_value) = arg.extract_string_value() {
66                if string_value.trim().is_empty() {
67                    return;
68                }
69                // Skip strings with no alphabetic characters (numbers,
70                // punctuation, format specifiers, etc.)
71                if !string_value.chars().any(char::is_alphabetic) {
72                    return;
73                }
74                // Skip simple format strings like "%s" or "%d"
75                if matches!(string_value.as_bytes(), [b'%', c] if c.is_ascii_alphabetic()) {
76                    return;
77                }
78            }
79
80            let location = arg.location();
81            violations.push(self.violation_at(
82                &file.path,
83                location,
84                format!(
85                    "User-visible string in {}() should be wrapped with _(\"...\")",
86                    func_name
87                ),
88            ));
89        }
90    }
91
92    /// Returns argument index for functions that take translatable strings
93    fn get_translatable_param(&self, func_name: &str) -> Option<usize> {
94        match func_name {
95            "gtk_label_new" => Some(0),
96            "gtk_label_set_text" | "gtk_label_set_markup" | "gtk_label_set_label" => Some(1),
97
98            "gtk_button_new_with_label" | "gtk_button_new_with_mnemonic" => Some(0),
99            "gtk_button_set_label" => Some(1),
100
101            "gtk_window_set_title" => Some(1),
102
103            "gtk_header_bar_set_title" | "gtk_header_bar_set_subtitle" => Some(1),
104
105            "gtk_check_button_new_with_label" | "gtk_check_button_new_with_mnemonic" => Some(0),
106
107            "gtk_radio_button_new_with_label" | "gtk_radio_button_new_with_mnemonic" => Some(1),
108
109            "gtk_entry_set_placeholder_text" | "gtk_entry_set_text" => Some(1),
110
111            "gtk_dialog_add_button" => Some(1),
112
113            "gtk_message_dialog_new" => Some(4),
114            "gtk_message_dialog_set_markup" => Some(1),
115
116            "adw_message_dialog_new"
117            | "adw_message_dialog_set_heading"
118            | "adw_message_dialog_set_body"
119            | "adw_message_dialog_set_body_use_markup" => Some(1),
120            "adw_message_dialog_add_response" => Some(2),
121
122            "adw_status_page_set_title" | "adw_status_page_set_description" => Some(1),
123
124            "adw_toast_new" => Some(0),
125            "adw_toast_set_title" | "adw_toast_set_button_label" => Some(1),
126
127            "adw_preferences_group_set_title" | "adw_preferences_group_set_description" => Some(1),
128
129            "adw_preferences_row_set_title" => Some(1),
130
131            "adw_action_row_set_title" | "adw_action_row_set_subtitle" => Some(1),
132
133            "adw_entry_row_set_title" => Some(1),
134
135            "adw_combo_row_set_title" => Some(1),
136
137            "adw_expander_row_set_title" | "adw_expander_row_set_subtitle" => Some(1),
138
139            "adw_window_title_new" => Some(0),
140            "adw_window_title_set_title" | "adw_window_title_set_subtitle" => Some(1),
141
142            _ => None,
143        }
144    }
145}