1mod 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
23pub struct PerlPlugin {
25 graph_builder: PerlGraphBuilder,
26}
27
28impl PerlPlugin {
29 #[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
83fn 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 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}