Skip to main content

sqry_lang_ruby/
lib.rs

1//! Ruby language plugin for sqry
2//!
3//! Implements the `LanguagePlugin` trait for Ruby, providing:
4//! - AST parsing with tree-sitter
5//! - Scope extraction
6//! - Relation extraction via `RubyGraphBuilder` (calls, imports, exports, FFI)
7//!
8//! # Supported Features
9//!
10//! - Classes (regular, subclass, singleton class)
11//! - Modules (namespacing and mixins)
12//! - Methods (instance and class methods)
13//! - Singleton methods (class methods via `def self.method`)
14//! - Visibility modifiers (public, private, protected)
15//! - `attr_reader`, `attr_writer`, `attr_accessor` (property declarations)
16
17mod relations;
18
19pub use relations::RubyGraphBuilder;
20
21use sqry_core::ast::{Scope, ScopeId, link_nested_scopes};
22use sqry_core::plugin::{
23    LanguageMetadata, LanguagePlugin,
24    error::{ParseError, ScopeError},
25};
26use std::path::Path;
27use streaming_iterator::StreamingIterator;
28use tree_sitter::{Language, Parser, Query, QueryCursor, Tree};
29
30/// Ruby language plugin
31///
32/// Provides language support for Ruby source files (.rb).
33///
34/// # Supported Constructs
35///
36/// - Classes (`class`)
37/// - Modules (`module`)
38/// - Methods (`def`, instance methods)
39/// - Singleton methods (`def self.method`, class methods)
40///
41/// # Example
42///
43/// ```
44/// use sqry_lang_ruby::RubyPlugin;
45/// use sqry_core::plugin::LanguagePlugin;
46///
47/// let plugin = RubyPlugin::default();
48/// let metadata = plugin.metadata();
49/// assert_eq!(metadata.id, "ruby");
50/// assert_eq!(metadata.name, "Ruby");
51/// ```
52pub struct RubyPlugin {
53    graph_builder: RubyGraphBuilder,
54}
55
56impl RubyPlugin {
57    #[must_use]
58    pub fn new() -> Self {
59        Self {
60            graph_builder: RubyGraphBuilder::default(),
61        }
62    }
63}
64
65impl Default for RubyPlugin {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl LanguagePlugin for RubyPlugin {
72    fn metadata(&self) -> LanguageMetadata {
73        LanguageMetadata {
74            id: "ruby",
75            name: "Ruby",
76            version: env!("CARGO_PKG_VERSION"),
77            author: "Verivus Pty Ltd",
78            description: "Ruby language support for sqry",
79            tree_sitter_version: "0.25",
80        }
81    }
82
83    fn extensions(&self) -> &'static [&'static str] {
84        &["rb", "rake", "gemspec"]
85    }
86
87    fn language(&self) -> Language {
88        tree_sitter_ruby::LANGUAGE.into()
89    }
90
91    fn parse_ast(&self, content: &[u8]) -> Result<Tree, ParseError> {
92        let mut parser = Parser::new();
93        let language = self.language();
94
95        parser.set_language(&language).map_err(|e| {
96            ParseError::LanguageSetFailed(format!("Failed to set Ruby language: {e}"))
97        })?;
98
99        parser
100            .parse(content, None)
101            .ok_or(ParseError::TreeSitterFailed)
102    }
103
104    fn extract_scopes(
105        &self,
106        tree: &Tree,
107        content: &[u8],
108        file_path: &Path,
109    ) -> Result<Vec<Scope>, ScopeError> {
110        Self::extract_ruby_scopes(tree, content, file_path)
111    }
112
113    fn graph_builder(&self) -> Option<&dyn sqry_core::graph::GraphBuilder> {
114        Some(&self.graph_builder)
115    }
116}
117
118impl RubyPlugin {
119    /// Extract scope information from Ruby code
120    fn extract_ruby_scopes(
121        tree: &Tree,
122        content: &[u8],
123        file_path: &Path,
124    ) -> Result<Vec<Scope>, ScopeError> {
125        let root_node = tree.root_node();
126        let language = tree_sitter_ruby::LANGUAGE.into();
127
128        let scope_query = Self::scope_query_source();
129
130        let query = Query::new(&language, scope_query)
131            .map_err(|e| ScopeError::QueryCompilationFailed(e.to_string()))?;
132
133        let mut scopes = Vec::new();
134        let mut cursor = QueryCursor::new();
135        let mut query_matches = cursor.matches(&query, root_node, content);
136
137        while let Some(m) = query_matches.next() {
138            let mut scope_type = None;
139            let mut scope_name = None;
140            let mut scope_start = None;
141            let mut scope_end = None;
142
143            for capture in m.captures {
144                let capture_name = query.capture_names()[capture.index as usize];
145                let node = capture.node;
146
147                let capture_ext = std::path::Path::new(capture_name)
148                    .extension()
149                    .and_then(|ext| ext.to_str());
150
151                if capture_ext.is_some_and(|ext| ext.eq_ignore_ascii_case("type")) {
152                    scope_type = Some(capture_name.trim_end_matches(".type").to_string());
153                    scope_start = Some(node.start_position());
154                    scope_end = Some(node.end_position());
155                } else if capture_ext.is_some_and(|ext| ext.eq_ignore_ascii_case("name")) {
156                    scope_name = node
157                        .utf8_text(content)
158                        .ok()
159                        .map(std::string::ToString::to_string);
160                }
161            }
162
163            if let (Some(stype), Some(sname), Some(start), Some(end)) =
164                (scope_type, scope_name, scope_start, scope_end)
165            {
166                let scope = Scope {
167                    id: ScopeId::new(0),
168                    scope_type: stype,
169                    name: sname,
170                    file_path: file_path.to_path_buf(),
171                    start_line: start.row + 1,
172                    start_column: start.column,
173                    end_line: end.row + 1,
174                    end_column: end.column,
175                    parent_id: None,
176                };
177                scopes.push(scope);
178            }
179        }
180
181        scopes.sort_by_key(|s| (s.start_line, s.start_column));
182
183        link_nested_scopes(&mut scopes);
184
185        Ok(scopes)
186    }
187
188    /// Returns tree-sitter query source for scope extraction
189    fn scope_query_source() -> &'static str {
190        r"
191; Method scopes
192(method
193  name: (identifier) @method.name
194) @method.type
195
196; Singleton method scopes
197(singleton_method
198  name: (identifier) @singleton_method.name
199) @singleton_method.type
200
201; Class scopes
202(class
203  name: (constant) @class.name
204) @class.type
205
206; Module scopes
207(module
208  name: (constant) @module.name
209) @module.type
210"
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_metadata() {
220        let plugin = RubyPlugin::default();
221        let metadata = plugin.metadata();
222
223        assert_eq!(metadata.id, "ruby");
224        assert_eq!(metadata.name, "Ruby");
225        assert_eq!(metadata.author, "Verivus Pty Ltd");
226    }
227
228    #[test]
229    fn test_extensions() {
230        let plugin = RubyPlugin::default();
231        let extensions = plugin.extensions();
232
233        assert_eq!(extensions.len(), 3);
234        assert!(extensions.contains(&"rb"));
235        assert!(extensions.contains(&"rake"));
236        assert!(extensions.contains(&"gemspec"));
237    }
238
239    #[test]
240    fn test_language() {
241        let plugin = RubyPlugin::default();
242        let language = plugin.language();
243
244        assert!(language.abi_version() > 0);
245    }
246
247    #[test]
248    fn test_parse_ast_simple() {
249        let plugin = RubyPlugin::default();
250        let source = b"def hello; end";
251
252        let tree = plugin.parse_ast(source).unwrap();
253        assert!(!tree.root_node().has_error());
254    }
255}