Skip to main content

sqry_lang_perl/
lib.rs

1//! Perl language plugin.
2//!
3//! Provides graph-native extraction via `PerlGraphBuilder`, AST parsing,
4//! and scope extraction for Perl source files.
5
6mod preprocess;
7pub mod relations;
8
9pub use relations::PerlGraphBuilder;
10
11use preprocess::preprocess_content;
12use sqry_core::ast::{Scope, ScopeId, link_nested_scopes};
13use sqry_core::plugin::error::ScopeError;
14use sqry_core::plugin::{LanguageMetadata, LanguagePlugin};
15use std::borrow::Cow;
16use std::path::Path;
17use tree_sitter::{Language, Query, QueryCursor, StreamingIterator, Tree};
18
19const LANGUAGE_ID: &str = "perl";
20const LANGUAGE_NAME: &str = "Perl";
21const TREE_SITTER_VERSION: &str = "0.23";
22
23/// Perl language plugin implementation.
24pub struct PerlPlugin {
25    graph_builder: PerlGraphBuilder,
26}
27
28impl PerlPlugin {
29    /// Creates a new Perl plugin instance.
30    #[must_use]
31    pub fn new() -> Self {
32        Self {
33            graph_builder: PerlGraphBuilder,
34        }
35    }
36}
37
38impl Default for PerlPlugin {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44impl LanguagePlugin for PerlPlugin {
45    fn metadata(&self) -> LanguageMetadata {
46        LanguageMetadata {
47            id: LANGUAGE_ID,
48            name: LANGUAGE_NAME,
49            version: env!("CARGO_PKG_VERSION"),
50            author: "Verivus Pty Ltd",
51            description: "Perl language support for sqry",
52            tree_sitter_version: TREE_SITTER_VERSION,
53        }
54    }
55
56    fn extensions(&self) -> &'static [&'static str] {
57        &["pl", "pm", "t"]
58    }
59
60    fn language(&self) -> Language {
61        tree_sitter_perl_sqry::language()
62    }
63
64    fn preprocess<'a>(&self, content: &'a [u8]) -> Cow<'a, [u8]> {
65        preprocess_content(content)
66    }
67
68    fn extract_scopes(
69        &self,
70        tree: &Tree,
71        content: &[u8],
72        file_path: &Path,
73    ) -> Result<Vec<Scope>, ScopeError> {
74        let processed = self.preprocess(content);
75        extract_perl_scopes(tree, processed.as_ref(), file_path)
76    }
77
78    fn graph_builder(&self) -> Option<&dyn sqry_core::graph::GraphBuilder> {
79        Some(&self.graph_builder)
80    }
81}
82
83/// Extract scopes from Perl source using tree-sitter queries.
84fn extract_perl_scopes(
85    tree: &Tree,
86    content: &[u8],
87    file_path: &Path,
88) -> Result<Vec<Scope>, ScopeError> {
89    let root_node = tree.root_node();
90    let language = tree_sitter_perl_sqry::language();
91
92    // Perl scope query: packages, classes, subroutines, methods.
93    let scope_query = r"
94; Package statements (namespace scopes)
95(package_statement
96  name: (_) @namespace.name
97) @namespace.type
98
99; Class statements (Moose/Moo style)
100(class_statement
101  name: (_) @class.name
102) @class.type
103
104; Role statements
105(role_statement
106  name: (_) @role.name
107) @role.type
108
109; Subroutine declarations
110(subroutine_declaration_statement
111  name: (_) @function.name
112) @function.type
113
114; Method declarations (Moose/Moo style)
115(method_declaration_statement
116  name: (_) @method.name
117) @method.type
118";
119
120    let query = Query::new(&language, scope_query)
121        .map_err(|e| ScopeError::QueryCompilationFailed(e.to_string()))?;
122
123    let mut scopes = Vec::new();
124    let mut cursor = QueryCursor::new();
125    let mut query_matches = cursor.matches(&query, root_node, content);
126
127    while let Some(m) = query_matches.next() {
128        let mut scope_type = None;
129        let mut scope_name = None;
130        let mut scope_start = None;
131        let mut scope_end = None;
132
133        for capture in m.captures {
134            let capture_name = query.capture_names()[capture.index as usize];
135            let node = capture.node;
136
137            if let Some((prefix, suffix)) = capture_name.rsplit_once('.') {
138                match suffix {
139                    "type" => {
140                        scope_type = Some(prefix.to_string());
141                        scope_start = Some(node.start_position());
142                        scope_end = Some(node.end_position());
143                    }
144                    "name" => {
145                        scope_name = node.utf8_text(content).ok().map(clean_identifier);
146                    }
147                    _ => {}
148                }
149            }
150        }
151
152        if let (Some(stype), Some(sname), Some(start), Some(end)) =
153            (scope_type, scope_name, scope_start, scope_end)
154        {
155            let normalized_type = match stype.as_str() {
156                "namespace" => "namespace",
157                "class" | "role" => "class",
158                "function" | "method" => "function",
159                other => other,
160            };
161
162            let scope = Scope {
163                id: ScopeId::new(0),
164                scope_type: normalized_type.to_string(),
165                name: sname,
166                file_path: file_path.to_path_buf(),
167                start_line: start.row + 1,
168                start_column: start.column,
169                end_line: end.row + 1,
170                end_column: end.column,
171                parent_id: None,
172            };
173            scopes.push(scope);
174        }
175    }
176
177    scopes.sort_by_key(|s| (s.start_line, s.start_column));
178    link_nested_scopes(&mut scopes);
179    Ok(scopes)
180}
181
182fn clean_identifier(raw: &str) -> String {
183    raw.trim_matches(|c: char| c == '\'' || c == '"')
184        .trim_matches(|c: char| c == ';')
185        .trim()
186        .to_string()
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::fs;
193    use std::path::PathBuf;
194
195    fn load_fixture(name: &str) -> (Vec<u8>, PathBuf) {
196        let path = PathBuf::from(format!("tests/fixtures/{name}"));
197        let content = fs::read(&path).expect("failed to read fixture");
198        (content, path)
199    }
200
201    #[test]
202    fn test_plugin_metadata() {
203        let plugin = PerlPlugin::default();
204        let metadata = plugin.metadata();
205        assert_eq!(metadata.id, "perl");
206        assert_eq!(metadata.name, "Perl");
207    }
208
209    #[test]
210    fn test_extensions() {
211        let plugin = PerlPlugin::default();
212        assert_eq!(plugin.extensions(), &["pl", "pm", "t"]);
213    }
214
215    #[test]
216    fn test_can_parse() {
217        let plugin = PerlPlugin::default();
218        let content = b"package Example; sub foo { return 1; }";
219        let tree = plugin.parse_ast(content);
220        assert!(tree.is_ok());
221    }
222
223    #[test]
224    fn test_extract_scopes_from_fixture() {
225        let plugin = PerlPlugin::default();
226        let (content, path) = load_fixture("basic.pl");
227        let tree = plugin.parse_ast(&content).expect("parse fixture");
228        let scopes = plugin
229            .extract_scopes(&tree, &content, &path)
230            .expect("extract scopes");
231
232        assert!(
233            scopes
234                .iter()
235                .any(|s| s.name == "Example::App" && s.scope_type == "namespace"),
236            "package scope should be extracted"
237        );
238        assert!(
239            scopes
240                .iter()
241                .any(|s| s.name == "foo" && s.scope_type == "function"),
242            "subroutine scope should be extracted"
243        );
244        assert!(
245            scopes
246                .iter()
247                .any(|s| s.name == "bar" && s.scope_type == "function"),
248            "method scope should be extracted"
249        );
250    }
251
252    #[test]
253    fn test_pod_is_ignored_for_scopes() {
254        let plugin = PerlPlugin::default();
255        let (content, path) = load_fixture("pod.pl");
256        let tree = plugin.parse_ast(&content).expect("parse fixture");
257        let scopes = plugin
258            .extract_scopes(&tree, &content, &path)
259            .expect("extract scopes");
260
261        assert!(
262            scopes
263                .iter()
264                .any(|s| s.name == "pod_sub" && s.scope_type == "function"),
265            "pod_sub scope should be extracted"
266        );
267    }
268}