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 }