Skip to main content

normalize_languages/
ruby.rs

1//! Ruby language support.
2
3use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
4use tree_sitter::Node;
5
6/// Ruby language support.
7pub struct Ruby;
8
9impl Language for Ruby {
10    fn name(&self) -> &'static str {
11        "Ruby"
12    }
13    fn extensions(&self) -> &'static [&'static str] {
14        &["rb"]
15    }
16    fn grammar_name(&self) -> &'static str {
17        "ruby"
18    }
19
20    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
21        Some(self)
22    }
23
24    fn signature_suffix(&self) -> &'static str {
25        "; end"
26    }
27
28    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
29        let mut doc_lines: Vec<String> = Vec::new();
30        let mut prev = node.prev_sibling();
31
32        while let Some(sibling) = prev {
33            if sibling.kind() == "comment" {
34                let text = &content[sibling.byte_range()];
35                if let Some(line) = text.strip_prefix('#') {
36                    let line = line.strip_prefix(' ').unwrap_or(line);
37                    doc_lines.push(line.to_string());
38                } else {
39                    break;
40                }
41            } else {
42                break;
43            }
44            prev = sibling.prev_sibling();
45        }
46
47        if doc_lines.is_empty() {
48            return None;
49        }
50
51        doc_lines.reverse();
52        let joined = doc_lines.join("\n").trim().to_string();
53        if joined.is_empty() {
54            None
55        } else {
56            Some(joined)
57        }
58    }
59
60    fn extract_implements(&self, node: &Node, content: &str) -> crate::ImplementsInfo {
61        let mut implements = Vec::new();
62        let mut cursor = node.walk();
63        for child in node.children(&mut cursor) {
64            if child.kind() == "superclass" {
65                let mut sc = child.walk();
66                for t in child.children(&mut sc) {
67                    if t.kind() == "constant" || t.kind() == "scope_resolution" {
68                        implements.push(content[t.byte_range()].to_string());
69                    }
70                }
71            }
72        }
73        crate::ImplementsInfo {
74            is_interface: false,
75            implements,
76        }
77    }
78
79    fn build_signature(&self, node: &Node, content: &str) -> String {
80        let name = match self.node_name(node, content) {
81            Some(n) => n,
82            None => {
83                return content[node.byte_range()]
84                    .lines()
85                    .next()
86                    .unwrap_or("")
87                    .trim()
88                    .to_string();
89            }
90        };
91        match node.kind() {
92            "method" | "singleton_method" => format!("def {}", name),
93            "class" => format!("class {}", name),
94            "module" => format!("module {}", name),
95            _ => {
96                let text = &content[node.byte_range()];
97                text.lines().next().unwrap_or(text).trim().to_string()
98            }
99        }
100    }
101
102    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
103        // Ruby: require 'x' or require_relative 'x'
104        if import.is_relative {
105            format!("require_relative '{}'", import.module)
106        } else {
107            format!("require '{}'", import.module)
108        }
109    }
110
111    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
112        let name = symbol.name.as_str();
113        match symbol.kind {
114            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
115            crate::SymbolKind::Module => name == "tests" || name == "test",
116            _ => false,
117        }
118    }
119
120    fn test_file_globs(&self) -> &'static [&'static str] {
121        &[
122            "**/spec/**/*.rb",
123            "**/test/**/*.rb",
124            "**/*_test.rb",
125            "**/*_spec.rb",
126        ]
127    }
128
129    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
130        // Ruby uses `private`, `protected`, `public` as method calls that change
131        // visibility for all subsequent method definitions in the class body.
132        // Walk backward through siblings to find the most recent visibility call.
133        let mut prev = node.prev_sibling();
134        while let Some(sibling) = prev {
135            if sibling.kind() == "call" || sibling.kind() == "identifier" {
136                let text = &content[sibling.byte_range()];
137                let method = text.split_whitespace().next().unwrap_or(text);
138                match method {
139                    "private" => return Visibility::Private,
140                    "protected" => return Visibility::Protected,
141                    "public" => return Visibility::Public,
142                    _ => {}
143                }
144            }
145            prev = sibling.prev_sibling();
146        }
147        // Ruby default is public
148        Visibility::Public
149    }
150
151    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
152        node.child_by_field_name("body")
153    }
154    fn analyze_container_body(
155        &self,
156        body_node: &Node,
157        content: &str,
158        inner_indent: &str,
159    ) -> Option<ContainerBody> {
160        crate::body::analyze_end_body(body_node, content, inner_indent)
161    }
162
163    fn extract_module_doc(&self, src: &str) -> Option<String> {
164        extract_ruby_module_doc(src)
165    }
166}
167
168impl LanguageSymbols for Ruby {}
169
170/// Extract the module-level doc comment from Ruby source.
171///
172/// Collects leading `#` comment lines, skipping `# frozen_string_literal` and
173/// similar magic comment lines (which appear before actual doc comments).
174fn extract_ruby_module_doc(src: &str) -> Option<String> {
175    let mut lines = Vec::new();
176    let mut past_magic = false;
177    for line in src.lines() {
178        let trimmed = line.trim();
179        if trimmed.is_empty() {
180            if lines.is_empty() {
181                continue; // skip leading blank lines
182            } else {
183                break; // blank line ends the comment block
184            }
185        }
186        if trimmed.starts_with('#') {
187            let text = trimmed.strip_prefix('#').unwrap_or("").trim_start();
188            // Skip magic comments: frozen_string_literal, encoding, etc.
189            if !past_magic
190                && (text.starts_with("frozen_string_literal")
191                    || text.starts_with("encoding")
192                    || text.starts_with("coding"))
193            {
194                continue;
195            }
196            past_magic = true;
197            lines.push(text.to_string());
198        } else {
199            break; // non-comment, non-blank line ends the block
200        }
201    }
202    if lines.is_empty() {
203        return None;
204    }
205    // Strip trailing empty comment lines
206    while lines.last().map(|l: &String| l.is_empty()).unwrap_or(false) {
207        lines.pop();
208    }
209    if lines.is_empty() {
210        None
211    } else {
212        Some(lines.join("\n"))
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::validate_unused_kinds_audit;
220
221    #[test]
222    fn unused_node_kinds_audit() {
223        #[rustfmt::skip]
224        let documented_unused: &[&str] = &[
225            // STRUCTURAL
226            "begin_block", "block_argument", "block_body", "block_parameter", "block_parameters",
227            "body_statement", "class_variable", "destructured_left_assignment",
228            "destructured_parameter", "else", "elsif", "empty_statement", "end_block",
229            "exception_variable", "exceptions", "expression_reference_pattern", "forward_argument",
230            "forward_parameter", "heredoc_body", "lambda_parameters",
231            "method_parameters", "operator", "operator_assignment", "parenthesized_statements", "superclass",
232            // CLAUSE
233            "case_match", "if_guard", "if_modifier", "in_clause", "match_pattern",
234            "rescue_modifier", "unless_modifier", "until_modifier", "while_modifier",
235            // EXPRESSION
236            "yield",
237            // control flow — not extracted as symbols
238            "case",
239            "while",
240            "block",
241            "retry",
242            "do_block",
243            "return",
244            "for",
245            "if",
246            "lambda",
247        ];
248
249        validate_unused_kinds_audit(&Ruby, documented_unused)
250            .expect("Ruby unused node kinds audit failed");
251    }
252}