1use std::path::Path;
4
5use crate::{
6 ContainerBody, Import, ImportSpec, Language, LanguageSymbols, ModuleId, ModuleResolver,
7 Resolution, ResolverConfig, Visibility,
8};
9use tree_sitter::Node;
10
11pub 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 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 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 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
180pub 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 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 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 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
270fn 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; } else {
283 break; }
285 }
286 if trimmed.starts_with('#') {
287 let text = trimmed.strip_prefix('#').unwrap_or("").trim_start();
288 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; }
301 }
302 if lines.is_empty() {
303 return None;
304 }
305 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 "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 "case_match", "if_guard", "if_modifier", "in_clause", "match_pattern",
334 "rescue_modifier", "unless_modifier", "until_modifier", "while_modifier",
335 "yield",
337 "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}