/ src / languages / ruby.rs
ruby.rs
  1  use crate::languages::LanguageSupport;
  2  use crate::types::EnvSourceKind;
  3  use std::sync::OnceLock;
  4  use tracing::error;
  5  use tree_sitter::{Language, Node, Query};
  6  
  7  pub struct Ruby;
  8  
  9  static REFERENCE_QUERY: OnceLock<Query> = OnceLock::new();
 10  static BINDING_QUERY: OnceLock<Query> = OnceLock::new();
 11  static IMPORT_QUERY: OnceLock<Query> = OnceLock::new();
 12  static COMPLETION_QUERY: OnceLock<Query> = OnceLock::new();
 13  static REASSIGNMENT_QUERY: OnceLock<Query> = OnceLock::new();
 14  static IDENTIFIER_QUERY: OnceLock<Query> = OnceLock::new();
 15  static EXPORT_QUERY: OnceLock<Query> = OnceLock::new();
 16  
 17  static ASSIGNMENT_QUERY: OnceLock<Query> = OnceLock::new();
 18  static DESTRUCTURE_QUERY: OnceLock<Query> = OnceLock::new();
 19  static SCOPE_QUERY: OnceLock<Query> = OnceLock::new();
 20  
 21  /// Compiles a tree-sitter query and fails fast on errors to surface invalid language query definitions early.
 22  fn compile_query(grammar: &Language, source: &str, query_name: &str) -> Query {
 23      match Query::new(grammar, source) {
 24          Ok(query) => query,
 25          Err(e) => {
 26              error!(
 27                  language = "ruby",
 28                  query = query_name,
 29                  error = %e,
 30                  "Failed to compile query, failing fast"
 31              );
 32              panic!("Failed to compile query '{}': {}", query_name, e)
 33          }
 34      }
 35  }
 36  
 37  impl LanguageSupport for Ruby {
 38      fn id(&self) -> &'static str {
 39          "ruby"
 40      }
 41  
 42      fn is_standard_env_object(&self, name: &str) -> bool {
 43          // Ruby uses the ENV constant
 44          name == "ENV"
 45      }
 46  
 47      fn default_env_object_name(&self) -> Option<&'static str> {
 48          Some("ENV")
 49      }
 50  
 51      fn is_scope_node(&self, node: Node) -> bool {
 52          matches!(
 53              node.kind(),
 54              "program"
 55                  | "method"
 56                  | "singleton_method"
 57                  | "class"
 58                  | "module"
 59                  | "block"
 60                  | "do_block"
 61                  | "lambda"
 62                  | "for"
 63                  | "if"
 64                  | "unless"
 65                  | "case"
 66                  | "while"
 67                  | "until"
 68                  | "begin"
 69          )
 70      }
 71  
 72      fn extensions(&self) -> &'static [&'static str] {
 73          &["rb", "rake", "gemspec", "ru"]
 74      }
 75  
 76      fn language_ids(&self) -> &'static [&'static str] {
 77          &["ruby"]
 78      }
 79  
 80      fn grammar(&self) -> Language {
 81          tree_sitter_ruby::LANGUAGE.into()
 82      }
 83  
 84      fn reference_query(&self) -> &Query {
 85          REFERENCE_QUERY.get_or_init(|| {
 86              compile_query(
 87                  &self.grammar(),
 88                  include_str!("../../queries/ruby/references.scm"),
 89                  "references",
 90              )
 91          })
 92      }
 93  
 94      fn binding_query(&self) -> Option<&Query> {
 95          Some(BINDING_QUERY.get_or_init(|| {
 96              compile_query(
 97                  &self.grammar(),
 98                  include_str!("../../queries/ruby/bindings.scm"),
 99                  "bindings",
100              )
101          }))
102      }
103  
104      fn import_query(&self) -> Option<&Query> {
105          Some(IMPORT_QUERY.get_or_init(|| {
106              compile_query(
107                  &self.grammar(),
108                  include_str!("../../queries/ruby/imports.scm"),
109                  "imports",
110              )
111          }))
112      }
113  
114      fn completion_query(&self) -> Option<&Query> {
115          Some(COMPLETION_QUERY.get_or_init(|| {
116              compile_query(
117                  &self.grammar(),
118                  include_str!("../../queries/ruby/completion.scm"),
119                  "completion",
120              )
121          }))
122      }
123  
124      fn reassignment_query(&self) -> Option<&Query> {
125          Some(REASSIGNMENT_QUERY.get_or_init(|| {
126              compile_query(
127                  &self.grammar(),
128                  include_str!("../../queries/ruby/reassignments.scm"),
129                  "reassignments",
130              )
131          }))
132      }
133  
134      fn identifier_query(&self) -> Option<&Query> {
135          Some(IDENTIFIER_QUERY.get_or_init(|| {
136              compile_query(
137                  &self.grammar(),
138                  include_str!("../../queries/ruby/identifiers.scm"),
139                  "identifiers",
140              )
141          }))
142      }
143  
144      fn export_query(&self) -> Option<&Query> {
145          Some(EXPORT_QUERY.get_or_init(|| {
146              compile_query(
147                  &self.grammar(),
148                  include_str!("../../queries/ruby/exports.scm"),
149                  "exports",
150              )
151          }))
152      }
153  
154      fn assignment_query(&self) -> Option<&Query> {
155          Some(ASSIGNMENT_QUERY.get_or_init(|| {
156              compile_query(
157                  &self.grammar(),
158                  include_str!("../../queries/ruby/assignments.scm"),
159                  "assignments",
160              )
161          }))
162      }
163  
164      fn destructure_query(&self) -> Option<&Query> {
165          Some(DESTRUCTURE_QUERY.get_or_init(|| {
166              compile_query(
167                  &self.grammar(),
168                  include_str!("../../queries/ruby/destructures.scm"),
169                  "destructures",
170              )
171          }))
172      }
173  
174      fn scope_query(&self) -> Option<&Query> {
175          Some(SCOPE_QUERY.get_or_init(|| {
176              compile_query(
177                  &self.grammar(),
178                  include_str!("../../queries/ruby/scopes.scm"),
179                  "scopes",
180              )
181          }))
182      }
183  
184      fn is_env_source_node(&self, node: Node, source: &[u8]) -> Option<EnvSourceKind> {
185          // Detect ENV constant
186          if node.kind() == "constant" {
187              let text = node.utf8_text(source).ok()?;
188              if text == "ENV" {
189                  return Some(EnvSourceKind::Object {
190                      canonical_name: "ENV".into(),
191                  });
192              }
193          }
194  
195          None
196      }
197  
198      fn known_env_modules(&self) -> &'static [&'static str] {
199          &["dotenv"]
200      }
201  
202      fn completion_trigger_characters(&self) -> &'static [&'static str] {
203          // Trigger on opening quote after array subscript or method call
204          &["[\"", "['", "(\"", "('"]
205      }
206  
207      fn strip_quotes<'a>(&self, text: &'a str) -> &'a str {
208          text.trim_matches(|c| c == '"' || c == '\'')
209      }
210  
211      fn extract_var_name(&self, node: Node, source: &[u8]) -> Option<compact_str::CompactString> {
212          node.utf8_text(source)
213              .ok()
214              .map(|s| compact_str::CompactString::from(self.strip_quotes(s)))
215      }
216  
217      fn extract_property_access(
218          &self,
219          tree: &tree_sitter::Tree,
220          content: &str,
221          byte_offset: usize,
222      ) -> Option<(compact_str::CompactString, compact_str::CompactString)> {
223          let node = tree
224              .root_node()
225              .descendant_for_byte_range(byte_offset, byte_offset)?;
226  
227          // In Ruby, method calls are through "call" nodes
228          let call_node = if node.kind() == "call" {
229              node
230          } else if let Some(parent) = node.parent() {
231              if parent.kind() == "call" {
232                  parent
233              } else {
234                  return None;
235              }
236          } else {
237              return None;
238          };
239  
240          let receiver_node = call_node.child_by_field_name("receiver")?;
241          let method_node = call_node.child_by_field_name("method")?;
242  
243          let receiver_name = receiver_node.utf8_text(content.as_bytes()).ok()?;
244          let method_name = method_node.utf8_text(content.as_bytes()).ok()?;
245  
246          Some((receiver_name.into(), method_name.into()))
247      }
248  
249      fn comment_node_kinds(&self) -> &'static [&'static str] {
250          &["comment"]
251      }
252  
253      fn is_root_node(&self, node: Node) -> bool {
254          node.kind() == "program"
255      }
256  }
257  
258  #[cfg(test)]
259  mod tests {
260      use super::*;
261  
262      fn get_ruby() -> Ruby {
263          Ruby
264      }
265  
266      #[test]
267      fn test_id() {
268          assert_eq!(get_ruby().id(), "ruby");
269      }
270  
271      #[test]
272      fn test_extensions() {
273          let exts = get_ruby().extensions();
274          assert!(exts.contains(&"rb"));
275          assert!(exts.contains(&"rake"));
276          assert!(exts.contains(&"gemspec"));
277      }
278  
279      #[test]
280      fn test_language_ids() {
281          let ids = get_ruby().language_ids();
282          assert!(ids.contains(&"ruby"));
283      }
284  
285      #[test]
286      fn test_is_standard_env_object() {
287          let ruby = get_ruby();
288          assert!(ruby.is_standard_env_object("ENV"));
289          assert!(!ruby.is_standard_env_object("process"));
290          assert!(!ruby.is_standard_env_object("os"));
291      }
292  
293      #[test]
294      fn test_default_env_object_name() {
295          assert_eq!(get_ruby().default_env_object_name(), Some("ENV"));
296      }
297  
298      #[test]
299      fn test_known_env_modules() {
300          let modules = get_ruby().known_env_modules();
301          assert!(modules.contains(&"dotenv"));
302      }
303  
304      #[test]
305      fn test_grammar_compiles() {
306          let ruby = get_ruby();
307          let _grammar = ruby.grammar();
308      }
309  
310      #[test]
311      fn test_strip_quotes() {
312          let ruby = get_ruby();
313          assert_eq!(ruby.strip_quotes("\"hello\""), "hello");
314          assert_eq!(ruby.strip_quotes("'world'"), "world");
315          assert_eq!(ruby.strip_quotes("noquotes"), "noquotes");
316      }
317  
318      #[test]
319      fn test_is_env_source_node_env() {
320          let ruby = get_ruby();
321          let mut parser = tree_sitter::Parser::new();
322          parser.set_language(&ruby.grammar()).unwrap();
323  
324          let code = "x = ENV['VAR']";
325          let tree = parser.parse(code, None).unwrap();
326          let root = tree.root_node();
327  
328          fn walk_tree(cursor: &mut tree_sitter::TreeCursor, ruby: &Ruby, code: &str) -> bool {
329              loop {
330                  let node = cursor.node();
331                  if node.kind() == "constant" {
332                      if let Some(kind) = ruby.is_env_source_node(node, code.as_bytes()) {
333                          if let EnvSourceKind::Object { canonical_name } = kind {
334                              if canonical_name == "ENV" {
335                                  return true;
336                              }
337                          }
338                      }
339                  }
340  
341                  if cursor.goto_first_child() {
342                      if walk_tree(cursor, ruby, code) {
343                          return true;
344                      }
345                      cursor.goto_parent();
346                  }
347  
348                  if !cursor.goto_next_sibling() {
349                      break;
350                  }
351              }
352              false
353          }
354  
355          let mut cursor = root.walk();
356          let found = walk_tree(&mut cursor, &ruby, code);
357          assert!(found, "Should detect ENV as env source");
358      }
359  
360      #[test]
361      fn test_is_scope_node() {
362          let ruby = get_ruby();
363          let mut parser = tree_sitter::Parser::new();
364          parser.set_language(&ruby.grammar()).unwrap();
365  
366          let code = "def test\nend";
367          let tree = parser.parse(code, None).unwrap();
368          let root = tree.root_node();
369  
370          fn find_node_of_kind<'a>(
371              node: tree_sitter::Node<'a>,
372              kind: &str,
373          ) -> Option<tree_sitter::Node<'a>> {
374              if node.kind() == kind {
375                  return Some(node);
376              }
377              for i in 0..node.child_count() {
378                  if let Some(child) = node.child(i) {
379                      if let Some(found) = find_node_of_kind(child, kind) {
380                          return Some(found);
381                      }
382                  }
383              }
384              None
385          }
386  
387          if let Some(method) = find_node_of_kind(root, "method") {
388              assert!(ruby.is_scope_node(method));
389          }
390      }
391  }