Skip to main content

sqry_lang_php/
lib.rs

1//! PHP language plugin for sqry
2//!
3//! Implements the `LanguagePlugin` trait for PHP, providing:
4//! - AST parsing with tree-sitter
5//! - Scope extraction
6//! - Relation extraction via `PhpGraphBuilder` (calls, imports, exports, OOP edges)
7//!
8//! This plugin enables semantic code search for PHP codebases, the #6 priority
9//! language powering 77% of websites (`WordPress`, Laravel, Symfony).
10
11pub mod relations;
12
13// Re-export graph builder types for testing
14pub use relations::PhpGraphBuilder;
15
16use sqry_core::ast::{Scope, ScopeId, link_nested_scopes};
17use sqry_core::plugin::{
18    LanguageMetadata, LanguagePlugin,
19    error::{ParseError, ScopeError},
20};
21use std::path::Path;
22use streaming_iterator::StreamingIterator;
23use tree_sitter::{Language, Parser, Query, QueryCursor, Tree};
24
25/// PHP language plugin
26///
27/// Provides language support for PHP files (.php).
28///
29/// # Supported Constructs
30///
31/// - Classes (`class Foo`)
32/// - Traits (`trait Bar`)
33/// - Interfaces (`interface IBaz`)
34/// - Functions (global functions, methods)
35/// - Namespaces (`namespace MyApp\Controllers`)
36///
37/// # Example
38///
39/// ```
40/// use sqry_lang_php::PhpPlugin;
41/// use sqry_core::plugin::LanguagePlugin;
42///
43/// let plugin = PhpPlugin::new();
44/// let metadata = plugin.metadata();
45/// assert_eq!(metadata.id, "php");
46/// assert_eq!(metadata.name, "PHP");
47/// ```
48pub struct PhpPlugin {
49    graph_builder: PhpGraphBuilder,
50}
51
52impl PhpPlugin {
53    /// Creates a new PHP plugin instance.
54    #[must_use]
55    pub fn new() -> Self {
56        Self {
57            graph_builder: PhpGraphBuilder::default(),
58        }
59    }
60}
61
62impl Default for PhpPlugin {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl LanguagePlugin for PhpPlugin {
69    fn metadata(&self) -> LanguageMetadata {
70        LanguageMetadata {
71            id: "php",
72            name: "PHP",
73            version: env!("CARGO_PKG_VERSION"),
74            author: "Verivus Pty Ltd",
75            description: "PHP language support for sqry - web application code search",
76            tree_sitter_version: "0.24",
77        }
78    }
79
80    fn extensions(&self) -> &'static [&'static str] {
81        &["php"]
82    }
83
84    fn language(&self) -> Language {
85        tree_sitter_php::LANGUAGE_PHP.into()
86    }
87
88    fn parse_ast(&self, content: &[u8]) -> Result<Tree, ParseError> {
89        let mut parser = Parser::new();
90        let language = self.language();
91
92        parser.set_language(&language).map_err(|e| {
93            ParseError::LanguageSetFailed(format!("Failed to set PHP language: {e}"))
94        })?;
95
96        parser
97            .parse(content, None)
98            .ok_or(ParseError::TreeSitterFailed)
99    }
100
101    fn extract_scopes(
102        &self,
103        tree: &Tree,
104        content: &[u8],
105        file_path: &Path,
106    ) -> Result<Vec<Scope>, ScopeError> {
107        Self::extract_php_scopes(tree, content, file_path)
108    }
109    fn graph_builder(&self) -> Option<&dyn sqry_core::graph::GraphBuilder> {
110        Some(&self.graph_builder)
111    }
112}
113
114impl PhpPlugin {
115    /// Extract scopes from PHP source using tree-sitter queries
116    fn extract_php_scopes(
117        tree: &Tree,
118        content: &[u8],
119        file_path: &Path,
120    ) -> Result<Vec<Scope>, ScopeError> {
121        let root_node = tree.root_node();
122        let language: Language = tree_sitter_php::LANGUAGE_PHP.into();
123
124        // PHP scope query for namespaces, classes, traits, interfaces, functions, methods
125        let scope_query = r"
126(namespace_definition
127  name: (namespace_name) @namespace.name
128) @namespace.type
129
130(class_declaration
131  name: (name) @class.name
132) @class.type
133
134(trait_declaration
135  name: (name) @trait.name
136) @trait.type
137
138(interface_declaration
139  name: (name) @interface.name
140) @interface.type
141
142(function_definition
143  name: (name) @function.name
144) @function.type
145
146(method_declaration
147  name: (name) @method.name
148) @method.type
149";
150
151        let query = Query::new(&language, scope_query)
152            .map_err(|e| ScopeError::QueryCompilationFailed(e.to_string()))?;
153
154        let mut scopes = Vec::new();
155        let mut cursor = QueryCursor::new();
156        let mut query_matches = cursor.matches(&query, root_node, content);
157
158        while let Some(m) = query_matches.next() {
159            let mut scope_type = None;
160            let mut scope_name = None;
161            let mut scope_start = None;
162            let mut scope_end = None;
163
164            for capture in m.captures {
165                let capture_name = query.capture_names()[capture.index as usize];
166                let node = capture.node;
167
168                if let Some((prefix, ext)) = capture_name.rsplit_once('.') {
169                    if ext.eq_ignore_ascii_case("type") {
170                        scope_type = Some(prefix.to_string());
171                        scope_start = Some(node.start_position());
172                        scope_end = Some(node.end_position());
173                    } else if ext.eq_ignore_ascii_case("name") {
174                        scope_name = node
175                            .utf8_text(content)
176                            .ok()
177                            .map(std::string::ToString::to_string);
178                    }
179                }
180            }
181
182            if let (Some(stype), Some(sname), Some(start), Some(end)) =
183                (scope_type, scope_name, scope_start, scope_end)
184            {
185                // Normalize scope type
186                let normalized_type = match stype.as_str() {
187                    "namespace" => "namespace",
188                    "class" | "trait" | "interface" => "class",
189                    "function" | "method" => "function",
190                    other => other,
191                };
192
193                scopes.push(Scope {
194                    id: ScopeId::new(0), // Will be reassigned by link_nested_scopes
195                    scope_type: normalized_type.to_string(),
196                    name: sname,
197                    file_path: file_path.to_path_buf(),
198                    start_line: start.row + 1,
199                    start_column: start.column,
200                    end_line: end.row + 1,
201                    end_column: end.column,
202                    parent_id: None,
203                });
204            }
205        }
206
207        // Sort by (start_line, start_column) for link_nested_scopes
208        scopes.sort_by_key(|s| (s.start_line, s.start_column));
209
210        link_nested_scopes(&mut scopes);
211        Ok(scopes)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_metadata() {
221        let plugin = PhpPlugin::default();
222        let metadata = plugin.metadata();
223
224        assert_eq!(metadata.id, "php");
225        assert_eq!(metadata.name, "PHP");
226        assert_eq!(metadata.version, env!("CARGO_PKG_VERSION"));
227        assert_eq!(metadata.author, "Verivus Pty Ltd");
228        assert_eq!(metadata.tree_sitter_version, "0.24");
229    }
230
231    #[test]
232    fn test_extensions() {
233        let plugin = PhpPlugin::default();
234        let extensions = plugin.extensions();
235
236        assert_eq!(extensions.len(), 1);
237        assert!(extensions.contains(&"php"));
238    }
239
240    #[test]
241    fn test_language() {
242        let plugin = PhpPlugin::default();
243        let language = plugin.language();
244
245        // Just verify we can get a language (ABI version should be non-zero)
246        assert!(language.abi_version() > 0);
247    }
248
249    #[test]
250    fn test_parse_ast_simple() {
251        let plugin = PhpPlugin::default();
252        let source = b"<?php class MyClass { }";
253
254        let tree = plugin.parse_ast(source).unwrap();
255        assert!(!tree.root_node().has_error());
256    }
257
258    #[test]
259    fn test_plugin_is_send_sync() {
260        fn assert_send_sync<T: Send + Sync>() {}
261        assert_send_sync::<PhpPlugin>();
262    }
263}