Skip to main content

normalize_languages/
ruby.rs

1//! Ruby language support.
2
3use std::path::Path;
4
5use crate::{
6    ContainerBody, Import, ImportSpec, Language, LanguageSymbols, ModuleId, ModuleResolver,
7    Resolution, ResolverConfig, Visibility,
8};
9use tree_sitter::Node;
10
11/// Ruby language support.
12pub struct Ruby;
13
14impl Language for Ruby {
15    fn name(&self) -> &'static str {
16        "Ruby"
17    }
18    fn extensions(&self) -> &'static [&'static str] {
19        &["rb"]
20    }
21    fn grammar_name(&self) -> &'static str {
22        "ruby"
23    }
24
25    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
26        Some(self)
27    }
28
29    fn signature_suffix(&self) -> &'static str {
30        "; end"
31    }
32
33    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
34        let mut doc_lines: Vec<String> = Vec::new();
35        let mut prev = node.prev_sibling();
36
37        while let Some(sibling) = prev {
38            if sibling.kind() == "comment" {
39                let text = &content[sibling.byte_range()];
40                if let Some(line) = text.strip_prefix('#') {
41                    let line = line.strip_prefix(' ').unwrap_or(line);
42                    doc_lines.push(line.to_string());
43                } else {
44                    break;
45                }
46            } else {
47                break;
48            }
49            prev = sibling.prev_sibling();
50        }
51
52        if doc_lines.is_empty() {
53            return None;
54        }
55
56        doc_lines.reverse();
57        let joined = doc_lines.join("\n").trim().to_string();
58        if joined.is_empty() {
59            None
60        } else {
61            Some(joined)
62        }
63    }
64
65    fn extract_implements(&self, node: &Node, content: &str) -> crate::ImplementsInfo {
66        let mut implements = Vec::new();
67        let mut cursor = node.walk();
68        for child in node.children(&mut cursor) {
69            if child.kind() == "superclass" {
70                let mut sc = child.walk();
71                for t in child.children(&mut sc) {
72                    if t.kind() == "constant" || t.kind() == "scope_resolution" {
73                        implements.push(content[t.byte_range()].to_string());
74                    }
75                }
76            }
77        }
78        crate::ImplementsInfo {
79            is_interface: false,
80            implements,
81        }
82    }
83
84    fn build_signature(&self, node: &Node, content: &str) -> String {
85        let name = match self.node_name(node, content) {
86            Some(n) => n,
87            None => {
88                return content[node.byte_range()]
89                    .lines()
90                    .next()
91                    .unwrap_or("")
92                    .trim()
93                    .to_string();
94            }
95        };
96        match node.kind() {
97            "method" | "singleton_method" => format!("def {}", name),
98            "class" => format!("class {}", name),
99            "module" => format!("module {}", name),
100            _ => {
101                let text = &content[node.byte_range()];
102                text.lines().next().unwrap_or(text).trim().to_string()
103            }
104        }
105    }
106
107    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
108        // Ruby: require 'x' or require_relative 'x'
109        if import.is_relative {
110            format!("require_relative '{}'", import.module)
111        } else {
112            format!("require '{}'", import.module)
113        }
114    }
115
116    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
117        let name = symbol.name.as_str();
118        match symbol.kind {
119            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
120            crate::SymbolKind::Module => name == "tests" || name == "test",
121            _ => false,
122        }
123    }
124
125    fn test_file_globs(&self) -> &'static [&'static str] {
126        &[
127            "**/spec/**/*.rb",
128            "**/test/**/*.rb",
129            "**/*_test.rb",
130            "**/*_spec.rb",
131        ]
132    }
133
134    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
135        // Ruby uses `private`, `protected`, `public` as method calls that change
136        // visibility for all subsequent method definitions in the class body.
137        // Walk backward through siblings to find the most recent visibility call.
138        let mut prev = node.prev_sibling();
139        while let Some(sibling) = prev {
140            if sibling.kind() == "call" || sibling.kind() == "identifier" {
141                let text = &content[sibling.byte_range()];
142                let method = text.split_whitespace().next().unwrap_or(text);
143                match method {
144                    "private" => return Visibility::Private,
145                    "protected" => return Visibility::Protected,
146                    "public" => return Visibility::Public,
147                    _ => {}
148                }
149            }
150            prev = sibling.prev_sibling();
151        }
152        // Ruby default is public
153        Visibility::Public
154    }
155
156    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
157        node.child_by_field_name("body")
158    }
159    fn analyze_container_body(
160        &self,
161        body_node: &Node,
162        content: &str,
163        inner_indent: &str,
164    ) -> Option<ContainerBody> {
165        crate::body::analyze_end_body(body_node, content, inner_indent)
166    }
167
168    fn extract_module_doc(&self, src: &str) -> Option<String> {
169        extract_ruby_module_doc(src)
170    }
171
172    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
173        static RESOLVER: RubyModuleResolver = RubyModuleResolver;
174        Some(&RESOLVER)
175    }
176}
177
178impl LanguageSymbols for Ruby {}
179
180// =============================================================================
181// Ruby Module Resolver
182// =============================================================================
183
184/// Module resolver for Ruby.
185///
186/// Handles `require_relative` imports (resolved against the caller's directory).
187/// Bare `require` calls (Gem dependencies) return `NotFound`.
188pub struct RubyModuleResolver;
189
190impl ModuleResolver for RubyModuleResolver {
191    fn workspace_config(&self, root: &Path) -> ResolverConfig {
192        ResolverConfig {
193            workspace_root: root.to_path_buf(),
194            path_mappings: Vec::new(),
195            search_roots: Vec::new(),
196        }
197    }
198
199    fn module_of_file(&self, _root: &Path, file: &Path, cfg: &ResolverConfig) -> Vec<ModuleId> {
200        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
201        if ext != "rb" {
202            return Vec::new();
203        }
204
205        let rel = file.strip_prefix(&cfg.workspace_root).unwrap_or(file);
206
207        let path_str = rel
208            .components()
209            .filter_map(|c| {
210                if let std::path::Component::Normal(s) = c {
211                    s.to_str()
212                } else {
213                    None
214                }
215            })
216            .collect::<Vec<_>>()
217            .join("/");
218
219        if path_str.is_empty() {
220            return Vec::new();
221        }
222
223        // Strip .rb extension
224        let canonical = path_str
225            .strip_suffix(".rb")
226            .unwrap_or(&path_str)
227            .to_string();
228
229        vec![ModuleId {
230            canonical_path: canonical,
231        }]
232    }
233
234    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
235        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
236        if ext != "rb" {
237            return Resolution::NotApplicable;
238        }
239
240        let raw = &spec.raw;
241
242        // Only resolve require_relative (is_relative = true)
243        if !spec.is_relative {
244            return Resolution::NotFound;
245        }
246
247        let base_dir = from_file.parent().unwrap_or(&cfg.workspace_root);
248        let candidate_base = base_dir.join(raw);
249
250        // Try with .rb extension first, then as-is
251        let with_rb = if candidate_base.extension().is_none() {
252            let mut p = candidate_base.clone();
253            p.set_extension("rb");
254            p
255        } else {
256            candidate_base.clone()
257        };
258
259        if with_rb.exists() {
260            return Resolution::Resolved(with_rb, String::new());
261        }
262        if candidate_base.exists() {
263            return Resolution::Resolved(candidate_base, String::new());
264        }
265
266        Resolution::NotFound
267    }
268}
269
270/// Extract the module-level doc comment from Ruby source.
271///
272/// Collects leading `#` comment lines, skipping `# frozen_string_literal` and
273/// similar magic comment lines (which appear before actual doc comments).
274fn extract_ruby_module_doc(src: &str) -> Option<String> {
275    let mut lines = Vec::new();
276    let mut past_magic = false;
277    for line in src.lines() {
278        let trimmed = line.trim();
279        if trimmed.is_empty() {
280            if lines.is_empty() {
281                continue; // skip leading blank lines
282            } else {
283                break; // blank line ends the comment block
284            }
285        }
286        if trimmed.starts_with('#') {
287            let text = trimmed.strip_prefix('#').unwrap_or("").trim_start();
288            // Skip magic comments: frozen_string_literal, encoding, etc.
289            if !past_magic
290                && (text.starts_with("frozen_string_literal")
291                    || text.starts_with("encoding")
292                    || text.starts_with("coding"))
293            {
294                continue;
295            }
296            past_magic = true;
297            lines.push(text.to_string());
298        } else {
299            break; // non-comment, non-blank line ends the block
300        }
301    }
302    if lines.is_empty() {
303        return None;
304    }
305    // Strip trailing empty comment lines
306    while lines.last().map(|l: &String| l.is_empty()).unwrap_or(false) {
307        lines.pop();
308    }
309    if lines.is_empty() {
310        None
311    } else {
312        Some(lines.join("\n"))
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::validate_unused_kinds_audit;
320
321    #[test]
322    fn unused_node_kinds_audit() {
323        #[rustfmt::skip]
324        let documented_unused: &[&str] = &[
325            // STRUCTURAL
326            "begin_block", "block_argument", "block_body", "block_parameter", "block_parameters",
327            "body_statement", "class_variable", "destructured_left_assignment",
328            "destructured_parameter", "else", "elsif", "empty_statement", "end_block",
329            "exception_variable", "exceptions", "expression_reference_pattern", "forward_argument",
330            "forward_parameter", "heredoc_body", "lambda_parameters",
331            "method_parameters", "operator", "operator_assignment", "parenthesized_statements", "superclass",
332            // CLAUSE
333            "case_match", "if_guard", "if_modifier", "in_clause", "match_pattern",
334            "rescue_modifier", "unless_modifier", "until_modifier", "while_modifier",
335            // EXPRESSION
336            "yield",
337            // control flow — not extracted as symbols
338            "case",
339            "while",
340            "block",
341            "retry",
342            "do_block",
343            "return",
344            "for",
345            "if",
346            "lambda",
347        ];
348
349        validate_unused_kinds_audit(&Ruby, documented_unused)
350            .expect("Ruby unused node kinds audit failed");
351    }
352}