From a55845ad2c7aa53b9b581fdafaf69f14c413fb2a Mon Sep 17 00:00:00 2001 From: jdrupal-dev <13871894+jdrupal-dev@users.noreply.github.com> Date: Tue, 22 Apr 2025 16:01:17 +0200 Subject: [PATCH 1/7] feat: parse class attributes and autocomplete plugins --- src/document_store/mod.rs | 31 ++++- src/documentation/mod.rs | 2 +- src/parser/mod.rs | 22 ++- src/parser/php.rs | 189 +++++++++++++++++++++----- src/parser/tokens.rs | 82 +++++++++-- src/parser/yaml.rs | 29 +--- src/server/handlers/completion/mod.rs | 76 ++++++++++- src/server/handlers/definition/mod.rs | 11 +- 8 files changed, 355 insertions(+), 87 deletions(-) diff --git a/src/document_store/mod.rs b/src/document_store/mod.rs index 2f5a11d..020d7d1 100644 --- a/src/document_store/mod.rs +++ b/src/document_store/mod.rs @@ -11,7 +11,9 @@ use lsp_types::TextDocumentContentChangeEvent; use rayon::iter::{IntoParallelIterator, ParallelIterator}; use url::Url; -use crate::parser::tokens::{PhpClassName, PhpMethod, Token, TokenData}; +use crate::parser::tokens::{ + ClassAttribute, DrupalPluginReference, PhpClassName, PhpMethod, Token, TokenData, +}; use self::document::{Document, FileType}; @@ -37,7 +39,7 @@ pub fn initialize_document_store(root_dir: String) { override_builder .add("!**/core/lib/**/*Interface.php") .unwrap(); - override_builder.add("!**/Plugin/**/*.php").unwrap(); + override_builder.add("!**/tests/**/*.php").unwrap(); override_builder.add("!vendor").unwrap(); override_builder.add("!node_modules").unwrap(); override_builder.add("!libraries").unwrap(); @@ -195,7 +197,7 @@ impl DocumentStore { } pub fn get_method_definition(&self, method: &PhpMethod) -> Option<(&Document, &Token)> { - if let Some((document, token)) = self.get_class_definition(&method.class_name) { + if let Some((document, token)) = self.get_class_definition(&method.get_class(self)?) { if let TokenData::PhpClassDefinition(class) = &token.data { let token = class.methods.get(&method.name)?; return Some((document, token)); @@ -222,7 +224,6 @@ impl DocumentStore { pub fn get_permission_definition(&self, permission_name: &str) -> Option<(&Document, &Token)> { let files = self.get_documents_by_file_type(FileType::Yaml); - log::info!("{}", permission_name); files.iter().find_map(|&document| { Some(( @@ -237,6 +238,28 @@ impl DocumentStore { }) } + pub fn get_plugin_definition( + &self, + plugin_reference: &DrupalPluginReference, + ) -> Option<(&Document, &Token)> { + let files = self.get_documents_by_file_type(FileType::Php); + + files.iter().find_map(|&document| { + Some(( + document, + document.tokens.iter().find(|token| { + if let TokenData::PhpClassDefinition(class) = &token.data { + if let Some(ClassAttribute::Plugin(plugin)) = &class.attribute { + return plugin.plugin_type == plugin_reference.plugin_type + && plugin.plugin_id == plugin_reference.plugin_id; + } + } + false + })?, + )) + }) + } + fn get_documents_by_file_type(&self, file_type: FileType) -> Vec<&Document> { self.documents .values() diff --git a/src/documentation/mod.rs b/src/documentation/mod.rs index ed1668f..80fa899 100644 --- a/src/documentation/mod.rs +++ b/src/documentation/mod.rs @@ -83,7 +83,7 @@ pub fn get_documentation_for_token(token: &Token) -> Option { } TokenData::PhpMethodReference(method) => Some(format!( "PHP Method reference\nclass: {}\nmethod: {}", - method.class_name, method.name + method.class_name.clone()?, method.name )), TokenData::DrupalRouteReference(route_name) => { let store = DOCUMENT_STORE.lock().unwrap(); diff --git a/src/parser/mod.rs b/src/parser/mod.rs index eec1b54..67a8602 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,8 +1,9 @@ -use tree_sitter::Node; - -pub mod yaml; pub mod php; pub mod tokens; +pub mod yaml; + +use lsp_types::Position; +use tree_sitter::{Language, Node, Parser, Point, Tree}; pub fn get_closest_parent_by_kind<'a>(node: &'a Node, kind: &'a str) -> Option> { let mut parent = node.parent(); @@ -11,3 +12,18 @@ pub fn get_closest_parent_by_kind<'a>(node: &'a Node, kind: &'a str) -> Option Option { + let mut parser = Parser::new(); + parser.set_language(language).ok()?; + parser.parse(source.as_bytes(), None) +} + +pub fn get_node_at_position(tree: &Tree, position: Position) -> Option { + let start = position_to_point(position); + tree.root_node().descendant_for_point_range(start, start) +} + +pub fn position_to_point(position: Position) -> Point { + Point::new(position.line as usize, position.character as usize) +} diff --git a/src/parser/php.rs b/src/parser/php.rs index b1e82f4..c4451c1 100644 --- a/src/parser/php.rs +++ b/src/parser/php.rs @@ -1,17 +1,18 @@ -use std::collections::HashMap; - use lsp_types::Position; -use tree_sitter::{Node, Parser, Point, Tree}; +use regex::Regex; +use std::collections::HashMap; +use tree_sitter::{Node, Point}; -use super::get_closest_parent_by_kind; -use super::tokens::{DrupalHook, PhpClass, PhpClassName, PhpMethod, Token, TokenData}; +use super::tokens::{ + ClassAttribute, DrupalHook, DrupalPlugin, DrupalPluginReference, DrupalPluginType, PhpClass, + PhpClassName, PhpMethod, Token, TokenData, +}; +use super::{get_closest_parent_by_kind, get_node_at_position, get_tree, position_to_point}; pub struct PhpParser { source: String, } -// TODO: A lot of code has been copied from the yaml parser. -// How can we DRY this up? impl PhpParser { pub fn new(source: &str) -> Self { Self { @@ -19,23 +20,15 @@ impl PhpParser { } } - pub fn get_tree(&self) -> Option { - let mut parser = Parser::new(); - parser - .set_language(&tree_sitter_php::LANGUAGE_PHP.into()) - .ok()?; - parser.parse(self.source.as_bytes(), None) - } - pub fn get_tokens(&self) -> Vec { - let tree = self.get_tree(); + let tree = get_tree(&self.source, &tree_sitter_php::LANGUAGE_PHP.into()); self.parse_nodes(vec![tree.unwrap().root_node()]) } pub fn get_token_at_position(&self, position: Position) -> Option { - let tree = self.get_tree()?; - let mut node = self.get_node_at_position(&tree, position)?; - let point = self.position_to_point(position); + let tree = get_tree(&self.source, &tree_sitter_php::LANGUAGE_PHP.into())?; + let mut node = get_node_at_position(&tree, position)?; + let point = position_to_point(position); // Return the first "parseable" token in the parent chain. let mut parsed_node: Option; @@ -49,15 +42,6 @@ impl PhpParser { parsed_node } - fn get_node_at_position<'a>(&self, tree: &'a Tree, position: Position) -> Option> { - let start = self.position_to_point(position); - tree.root_node().descendant_for_point_range(start, start) - } - - fn position_to_point(&self, position: Position) -> Point { - Point::new(position.line as usize, position.character as usize) - } - fn parse_nodes(&self, nodes: Vec) -> Vec { let mut tokens: Vec = vec![]; @@ -138,13 +122,31 @@ impl PhpParser { fn parse_call_expression(&self, node: Node, point: Option) -> Option { let string_content = node.descendant_for_point_range(point?, point?)?; + let name_node = node.child_by_field_name("name")?; + let name = self.get_node_text(&name_node); + + if node.kind() == "member_call_expression" { + let object_node = node.child_by_field_name("object")?; + if self.get_node_text(&object_node).contains("Drupal::service") { + let arguments = object_node.child_by_field_name("arguments")?; + let service_name = self + .get_node_text(&arguments) + .trim_matches(|c| c == '\'' || c == '(' || c == ')'); + return Some(Token::new( + TokenData::PhpMethodReference(PhpMethod { + name: name.to_string(), + class_name: None, + service_name: Some(service_name.to_string()), + }), + node.range(), + )); + } + } + if string_content.kind() != "string_content" { return None; } - let name_node = node.child_by_field_name("name")?; - let name = self.get_node_text(&name_node); - if name == "fromRoute" || name == "createFromRoute" || name == "setRedirect" { return Some(Token::new( TokenData::DrupalRouteReference(self.get_node_text(&string_content).to_string()), @@ -165,16 +167,67 @@ impl PhpParser { } // TODO: This is a quite primitive way to detect ContainerInterface::get. // Can we somehow get the interface of a given variable? - if name == "get" { + else if name == "get" { let object_node = node.child_by_field_name("object")?; - if self.get_node_text(&object_node) == "$container" { + let object = self.get_node_text(&object_node); + if object == "$container" { return Some(Token::new( TokenData::DrupalServiceReference( self.get_node_text(&string_content).to_string(), ), node.range(), )); + } else if object.contains("queueFactory") { + return Some(Token::new( + TokenData::DrupalPluginReference(DrupalPluginReference { + plugin_type: DrupalPluginType::QueueWorker, + plugin_id: self.get_node_text(&string_content).to_string(), + }), + node.range(), + )); + } + } else if name == "getStorage" { + let object_node = node.child_by_field_name("object")?; + let object = self.get_node_text(&object_node); + if object.contains("entityTypeManager") { + return Some(Token::new( + TokenData::DrupalPluginReference(DrupalPluginReference { + plugin_type: DrupalPluginType::EntityType, + plugin_id: self.get_node_text(&string_content).to_string(), + }), + node.range(), + )); + } + } else if name == "create" { + let scope_node = node.child_by_field_name("scope")?; + if self + .get_node_text(&scope_node) + .contains("BaseFieldDefinition") + { + return Some(Token::new( + TokenData::DrupalPluginReference(DrupalPluginReference { + plugin_type: DrupalPluginType::FieldType, + plugin_id: self.get_node_text(&string_content).to_string(), + }), + node.range(), + )); + } else if self.get_node_text(&scope_node).contains("DataDefinition") { + return Some(Token::new( + TokenData::DrupalPluginReference(DrupalPluginReference { + plugin_type: DrupalPluginType::DataType, + plugin_id: self.get_node_text(&string_content).to_string(), + }), + node.range(), + )); } + } else if name == "queue" { + return Some(Token::new( + TokenData::DrupalPluginReference(DrupalPluginReference { + plugin_type: DrupalPluginType::QueueWorker, + plugin_id: self.get_node_text(&string_content).to_string(), + }), + node.range(), + )); } None @@ -193,14 +246,47 @@ impl PhpParser { }); } - let token = Token::new( + let mut class_attribute = None; + if let Some(attributes_node) = node.child_by_field_name("attributes") { + let attribute_group = attributes_node.child(0)?; + class_attribute = self.parse_class_attribute(attribute_group.named_child(0)?); + } else if let Some(comment_node) = node.prev_named_sibling() { + if comment_node.kind() == "comment" { + let text = self.get_node_text(&comment_node); + + let re = Regex::new(r#"\*\s*@(?.+)\("#).unwrap(); + let mut plugin_type: Option = None; + if let Some(captures) = re.captures(text) { + if let Some(str) = captures.name("type") { + plugin_type = DrupalPluginType::try_from(str.as_str()).ok(); + } + } + + let re = Regex::new(r#"id\s*=\s*"(?[^"]+)""#).unwrap(); + let mut plugin_id: Option = None; + if let Some(captures) = re.captures(text) { + if let Some(str) = captures.name("id") { + plugin_id = Some(str.as_str().to_string()); + } + } + + if let (Some(plugin_type), Some(plugin_id)) = (plugin_type, plugin_id) { + class_attribute = Some(ClassAttribute::Plugin(DrupalPlugin { + plugin_type, + plugin_id, + })); + }; + } + } + + Some(Token::new( TokenData::PhpClassDefinition(PhpClass { name: self.get_class_name_from_node(node)?, + attribute: class_attribute, methods, }), node.range(), - ); - Some(token) + )) } fn parse_method_declaration(&self, node: Node) -> Option { @@ -214,12 +300,41 @@ impl PhpParser { Some(Token::new( TokenData::PhpMethodDefinition(PhpMethod { name: self.get_node_text(&name_node).to_string(), - class_name: self.get_class_name_from_node(class_node)?, + class_name: self.get_class_name_from_node(class_node), + service_name: None, }), node.range(), )) } + fn parse_class_attribute(&self, node: Node) -> Option { + if node.kind() != "attribute" { + return None; + } + + let mut plugin_id = String::default(); + + // TODO: Look into improving this if we want to extract more than plugin id. + let parameters_node = node.child_by_field_name("parameters")?; + for argument in parameters_node.named_children(&mut parameters_node.walk()) { + let argument_name = argument.child_by_field_name("name")?; + if self.get_node_text(&argument_name) == "id" { + plugin_id = self + .get_node_text(&argument.named_child(1)?) + .trim_matches(|c| c == '"' || c == '\'') + .to_string() + } + } + + match DrupalPluginType::try_from(self.get_node_text(&node.child(0)?)) { + Ok(plugin_type) => Some(ClassAttribute::Plugin(DrupalPlugin { + plugin_id, + plugin_type, + })), + Err(_) => None, + } + } + fn get_class_name_from_node(&self, node: Node) -> Option { if node.kind() != "class_declaration" { return None; diff --git a/src/parser/tokens.rs b/src/parser/tokens.rs index 2c84a89..cf5f105 100644 --- a/src/parser/tokens.rs +++ b/src/parser/tokens.rs @@ -1,7 +1,9 @@ use regex::Regex; -use std::collections::HashMap; +use std::{collections::HashMap, fmt}; use tree_sitter::Range; +use crate::document_store::DocumentStore; + #[derive(Debug)] pub struct Token { pub range: Range, @@ -28,17 +30,18 @@ pub enum TokenData { DrupalHookDefinition(DrupalHook), DrupalPermissionDefinition(DrupalPermission), DrupalPermissionReference(String), + DrupalPluginReference(DrupalPluginReference), } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub struct PhpClassName { value: String, } -impl std::fmt::Display for PhpClassName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.value) - } +impl fmt::Display for PhpClassName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.value) + } } impl From<&str> for PhpClassName { @@ -51,16 +54,38 @@ impl From<&str> for PhpClassName { } } +#[derive(Debug)] +pub enum ClassAttribute { + Plugin(DrupalPlugin), +} + #[derive(Debug)] pub struct PhpClass { pub name: PhpClassName, + pub attribute: Option, pub methods: HashMap>, } #[derive(Debug)] pub struct PhpMethod { pub name: String, - pub class_name: PhpClassName, + pub class_name: Option, + pub service_name: Option, +} + +impl PhpMethod { + pub fn get_class(&self, store: &DocumentStore) -> Option { + if let Some(class_name) = &self.class_name { + return Some(class_name.clone()); + } else if let Some(service_name) = &self.service_name { + if let Some((_, token)) = store.get_service_definition(service_name) { + if let TokenData::DrupalServiceDefinition(service) = &token.data { + return Some(service.class.clone()); + } + } + } + None + } } impl TryFrom<&str> for PhpMethod { @@ -70,7 +95,8 @@ impl TryFrom<&str> for PhpMethod { if let Some((class, method)) = value.trim_matches(['\'', '\\']).split_once("::") { return Ok(Self { name: method.to_string(), - class_name: PhpClassName::from(class), + class_name: Some(PhpClassName::from(class)), + service_name: None, }); } @@ -124,6 +150,46 @@ pub struct DrupalPermission { pub title: String, } +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum DrupalPluginType { + EntityType, + QueueWorker, + FieldType, + DataType, +} + +impl TryFrom<&str> for DrupalPluginType { + type Error = &'static str; + + fn try_from(value: &str) -> Result { + match value { + "ContentEntityType" | "ConfigEntityType" => Ok(DrupalPluginType::EntityType), + "QueueWorker" => Ok(DrupalPluginType::QueueWorker), + "FieldType" => Ok(DrupalPluginType::FieldType), + "DataType" => Ok(DrupalPluginType::DataType), + _ => Err("Unable to convert string to DrupalPluginType"), + } + } +} + +impl fmt::Display for DrupalPluginType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +#[derive(Debug)] +pub struct DrupalPlugin { + pub plugin_type: DrupalPluginType, + pub plugin_id: String, +} + +#[derive(Debug)] +pub struct DrupalPluginReference { + pub plugin_type: DrupalPluginType, + pub plugin_id: String, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/parser/yaml.rs b/src/parser/yaml.rs index e3b4376..408960c 100644 --- a/src/parser/yaml.rs +++ b/src/parser/yaml.rs @@ -1,12 +1,12 @@ use lsp_types::Position; use std::collections::HashMap; use std::vec; -use tree_sitter::{Node, Parser, Point, Tree}; +use tree_sitter::{Node, Point}; -use super::tokens::{ +use super::{get_node_at_position, get_tree, position_to_point, tokens::{ DrupalPermission, DrupalRoute, DrupalRouteDefaults, DrupalService, PhpClassName, PhpMethod, Token, TokenData, -}; +}}; pub struct YamlParser { source: String, @@ -21,21 +21,15 @@ impl YamlParser { } } - pub fn get_tree(&self) -> Option { - let mut parser = Parser::new(); - parser.set_language(&tree_sitter_yaml::language()).ok()?; - parser.parse(self.source.as_bytes(), None) - } - pub fn get_tokens(&self) -> Vec { - let tree = self.get_tree(); + let tree = get_tree(&self.source, &tree_sitter_yaml::language()); self.parse_nodes(vec![tree.unwrap().root_node()]) } pub fn get_token_at_position(&self, position: Position) -> Option { - let tree = self.get_tree()?; - let mut node = self.get_node_at_position(&tree, position)?; - let point = self.position_to_point(position); + let tree = get_tree(&self.source, &tree_sitter_yaml::language())?; + let mut node = get_node_at_position(&tree, position)?; + let point = position_to_point(position); // Return the first "parseable" token in the parent chain. let mut parsed_node: Option; @@ -49,15 +43,6 @@ impl YamlParser { parsed_node } - fn get_node_at_position<'a>(&self, tree: &'a Tree, position: Position) -> Option> { - let start = self.position_to_point(position); - tree.root_node().descendant_for_point_range(start, start) - } - - fn position_to_point(&self, position: Position) -> Point { - Point::new(position.line as usize, position.character as usize) - } - fn parse_nodes(&self, nodes: Vec) -> Vec { let mut tokens: Vec = vec![]; diff --git a/src/server/handlers/completion/mod.rs b/src/server/handlers/completion/mod.rs index ea7af7f..06ed145 100644 --- a/src/server/handlers/completion/mod.rs +++ b/src/server/handlers/completion/mod.rs @@ -10,7 +10,7 @@ use regex::Regex; use crate::document_store::DOCUMENT_STORE; use crate::documentation::get_documentation_for_token; -use crate::parser::tokens::{Token, TokenData}; +use crate::parser::tokens::{ClassAttribute, Token, TokenData}; use crate::server::handle_request::get_response_error; pub fn handle_text_document_completion(request: Request) -> Option { @@ -44,8 +44,9 @@ pub fn handle_text_document_completion(request: Request) -> Option { token = document.get_token_under_cursor(position); } - let mut completion_items: Vec = get_global_snippets(); + let (file_name, extension) = uri.split('/').last()?.split_once('.')?; + let mut completion_items: Vec = get_global_snippets(); if let Some(token) = token { if let TokenData::DrupalRouteReference(_) = token.data { let re = Regex::new(r"(?.*fromRoute\(')(?[^']*)'(?, \[.*\])?"); @@ -130,6 +131,10 @@ pub fn handle_text_document_completion(request: Request) -> Option { } completion_items.push(CompletionItem { label: route.name.clone(), + label_details: Some(CompletionItemLabelDetails { + description: Some("Route".to_string()), + detail: None, + }), kind: Some(CompletionItemKind::REFERENCE), documentation, text_edit, @@ -155,6 +160,10 @@ pub fn handle_text_document_completion(request: Request) -> Option { } completion_items.push(CompletionItem { label: service.name.clone(), + label_details: Some(CompletionItemLabelDetails { + description: Some("Service".to_string()), + detail: None, + }), kind: Some(CompletionItemKind::REFERENCE), documentation, deprecated: Some(false), @@ -163,6 +172,26 @@ pub fn handle_text_document_completion(request: Request) -> Option { } }) }); + } else if let TokenData::PhpMethodReference(method) = token.data { + let store = DOCUMENT_STORE.lock().unwrap(); + // TODO: Don't suggest private/protected methods. + if let Some((_, class_token)) = store.get_class_definition(&method.get_class(&store)?) { + if let TokenData::PhpClassDefinition(class) = &class_token.data { + class.methods.keys().for_each(|method_name| { + completion_items.push(CompletionItem { + label: method_name.clone(), + label_details: Some(CompletionItemLabelDetails { + description: Some("Method".to_string()), + detail: None, + }), + kind: Some(CompletionItemKind::REFERENCE), + documentation: None, + deprecated: Some(false), + ..CompletionItem::default() + }); + }); + } + } } else if let TokenData::DrupalPermissionReference(_) = token.data { DOCUMENT_STORE .lock() @@ -180,6 +209,10 @@ pub fn handle_text_document_completion(request: Request) -> Option { // label. completion_items.push(CompletionItem { label: permission.name.clone(), + label_details: Some(CompletionItemLabelDetails { + description: Some("Permission".to_string()), + detail: None, + }), kind: Some(CompletionItemKind::REFERENCE), documentation, deprecated: Some(false), @@ -188,11 +221,42 @@ pub fn handle_text_document_completion(request: Request) -> Option { } }) }); + } else if let TokenData::DrupalPluginReference(plugin_reference) = token.data { + DOCUMENT_STORE + .lock() + .unwrap() + .get_documents() + .values() + .for_each(|document| { + document.tokens.iter().for_each(|token| { + if let TokenData::PhpClassDefinition(class) = &token.data { + if let Some(ClassAttribute::Plugin(plugin)) = &class.attribute { + if plugin_reference.plugin_type == plugin.plugin_type { + let mut documentation = None; + if let Some(documentation_string) = + get_documentation_for_token(token) + { + documentation = + Some(Documentation::String(documentation_string)); + } + completion_items.push(CompletionItem { + label: plugin.plugin_id.clone(), + label_details: Some(CompletionItemLabelDetails { + description: Some(plugin.plugin_type.to_string()), + detail: None, + }), + kind: Some(CompletionItemKind::REFERENCE), + documentation, + deprecated: Some(false), + ..CompletionItem::default() + }); + } + } + } + }) + }); } - } - - let (file_name, extension) = uri.split('/').last()?.split_once('.')?; - if extension == "module" || extension == "theme" { + } else if extension == "module" || extension == "theme" { DOCUMENT_STORE .lock() .unwrap() diff --git a/src/server/handlers/definition/mod.rs b/src/server/handlers/definition/mod.rs index 47d4ec3..057f857 100644 --- a/src/server/handlers/definition/mod.rs +++ b/src/server/handlers/definition/mod.rs @@ -57,12 +57,11 @@ fn provide_definition_for_token(token: &Token) -> Option let (source_document, token) = match &token.data { TokenData::PhpClassReference(class) => store.get_class_definition(class), TokenData::PhpMethodReference(method) => store.get_method_definition(method), - TokenData::DrupalServiceReference(service_name) => { - store.get_service_definition(service_name) - } - TokenData::DrupalRouteReference(route_name) => store.get_route_definition(route_name), - TokenData::DrupalHookReference(hook_name) => store.get_hook_definition(hook_name), - TokenData::DrupalPermissionReference(permission_name) => store.get_permission_definition(permission_name), + TokenData::DrupalServiceReference(name) => store.get_service_definition(name), + TokenData::DrupalRouteReference(name) => store.get_route_definition(name), + TokenData::DrupalHookReference(name) => store.get_hook_definition(name), + TokenData::DrupalPermissionReference(name) => store.get_permission_definition(name), + TokenData::DrupalPluginReference(plugin_id) => store.get_plugin_definition(plugin_id), _ => None, }?; From bed0e7f2908cd5b7f953f2d80eb9b8863d044ede Mon Sep 17 00:00:00 2001 From: n1kolas Date: Tue, 22 Apr 2025 18:38:40 +0200 Subject: [PATCH 2/7] feat: add attribute parsing for form&render-elements and snippet autogeneration --- src/parser/php.rs | 53 +++++++++++++ src/parser/tokens.rs | 5 ++ src/server/handlers/completion/mod.rs | 110 +++++++++++++++++++------- 3 files changed, 138 insertions(+), 30 deletions(-) diff --git a/src/parser/php.rs b/src/parser/php.rs index c4451c1..5dbce71 100644 --- a/src/parser/php.rs +++ b/src/parser/php.rs @@ -274,11 +274,26 @@ impl PhpParser { class_attribute = Some(ClassAttribute::Plugin(DrupalPlugin { plugin_type, plugin_id, + usage_example: None, })); }; } } + // Try to get an usage example for snippet generation. + if let Some(attribute) = &mut class_attribute { + match attribute { + ClassAttribute::Plugin(ref mut drupal_plugin) => { + if let Some(prev_sibling) = node.prev_named_sibling() { + if prev_sibling.kind() == "comment" { + drupal_plugin.usage_example = + self.extract_usage_example_from_comment(&prev_sibling); + } + } + } + } + } + Some(Token::new( TokenData::PhpClassDefinition(PhpClass { name: self.get_class_name_from_node(node)?, @@ -317,6 +332,16 @@ impl PhpParser { // TODO: Look into improving this if we want to extract more than plugin id. let parameters_node = node.child_by_field_name("parameters")?; for argument in parameters_node.named_children(&mut parameters_node.walk()) { + // In the case of f.e `#[FormElement('date')]` there is no `id` field. + if self.get_node_text(&argument).starts_with("'") + && self.get_node_text(&argument).ends_with("'") + { + plugin_id = self + .get_node_text(&argument) + .trim_matches(|c| c == '"' || c == '\'') + .to_string(); + break; + } let argument_name = argument.child_by_field_name("name")?; if self.get_node_text(&argument_name) == "id" { plugin_id = self @@ -330,6 +355,7 @@ impl PhpParser { Ok(plugin_type) => Some(ClassAttribute::Plugin(DrupalPlugin { plugin_id, plugin_type, + usage_example: None, })), Err(_) => None, } @@ -360,4 +386,31 @@ impl PhpParser { fn get_node_text(&self, node: &Node) -> &str { node.utf8_text(self.source.as_bytes()).unwrap_or("") } + + /// Helper function to extract usage example from the preceding comment + fn extract_usage_example_from_comment(&self, comment_node: &Node) -> Option { + let comment_text = self.get_node_text(comment_node); + let start_tag = "@code"; + let end_tag = "@endcode"; + + if let Some(start_index) = comment_text.find(start_tag) { + if let Some(end_index) = comment_text.find(end_tag) { + if end_index > start_index { + let code_start = start_index + start_tag.len(); + let example = comment_text[code_start..end_index].trim(); + let cleaned_example = example + .lines() + .map(|line| line.trim_start().strip_prefix("* ").unwrap_or(line)) + .collect::>(); + + let result = &cleaned_example[..cleaned_example.len() - 1] + .join("\n") + .trim() + .to_string(); + return Some(result.to_string()); + } + } + } + None + } } diff --git a/src/parser/tokens.rs b/src/parser/tokens.rs index cf5f105..54977ab 100644 --- a/src/parser/tokens.rs +++ b/src/parser/tokens.rs @@ -156,6 +156,8 @@ pub enum DrupalPluginType { QueueWorker, FieldType, DataType, + FormElement, + RenderElement, } impl TryFrom<&str> for DrupalPluginType { @@ -167,6 +169,8 @@ impl TryFrom<&str> for DrupalPluginType { "QueueWorker" => Ok(DrupalPluginType::QueueWorker), "FieldType" => Ok(DrupalPluginType::FieldType), "DataType" => Ok(DrupalPluginType::DataType), + "FormElement" => Ok(DrupalPluginType::FormElement), + "RenderElement" => Ok(DrupalPluginType::RenderElement), _ => Err("Unable to convert string to DrupalPluginType"), } } @@ -182,6 +186,7 @@ impl fmt::Display for DrupalPluginType { pub struct DrupalPlugin { pub plugin_type: DrupalPluginType, pub plugin_id: String, + pub usage_example: Option, } #[derive(Debug)] diff --git a/src/server/handlers/completion/mod.rs b/src/server/handlers/completion/mod.rs index 06ed145..f8bec89 100644 --- a/src/server/handlers/completion/mod.rs +++ b/src/server/handlers/completion/mod.rs @@ -10,7 +10,7 @@ use regex::Regex; use crate::document_store::DOCUMENT_STORE; use crate::documentation::get_documentation_for_token; -use crate::parser::tokens::{ClassAttribute, Token, TokenData}; +use crate::parser::tokens::{ClassAttribute, DrupalPluginType, Token, TokenData}; use crate::server::handle_request::get_response_error; pub fn handle_text_document_completion(request: Request) -> Option { @@ -326,10 +326,9 @@ pub fn handle_text_document_completion(request: Request) -> Option { } fn get_global_snippets() -> Vec { - let mut snippets = HashMap::new(); - + let mut snippets: HashMap = HashMap::new(); snippets.insert( - "batch", + "batch".to_string(), r#" \$storage = \\Drupal::entityTypeManager()->getStorage('$0'); if (!isset(\$sandbox['ids'])) { @@ -347,58 +346,75 @@ foreach (\$storage->loadMultiple(\$ids) as \$entity) { if (\$sandbox['total'] > 0) { \$sandbox['#finished'] = (\$sandbox['total'] - count(\$sandbox['ids'])) / \$sandbox['total']; -}"#, +}"# + .to_string(), ); snippets.insert( - "ihdoc", + "ihdoc".to_string(), r#" /** * {@inheritdoc} - */"#, + */"# + .to_string(), ); snippets.insert( - "ensure-instanceof", - "if (!($1 instanceof $2)) {\n return$0;\n}", + "ensure-instanceof".to_string(), + "if (!($1 instanceof $2)) {\n return$0;\n}".to_string(), ); snippets.insert( - "entity-storage", - "\\$storage = \\$this->entityTypeManager->getStorage('$0');", + "entity-storage".to_string(), + "\\$storage = \\$this->entityTypeManager->getStorage('$0');".to_string(), ); snippets.insert( - "entity-load", - "\\$$1 = \\$this->entityTypeManager->getStorage('$1')->load($0);", + "entity-load".to_string(), + "\\$$1 = \\$this->entityTypeManager->getStorage('$1')->load($0);".to_string(), ); snippets.insert( - "entity-query", + "entity-query".to_string(), r#" \$ids = \$this->entityTypeManager->getStorage('$1')->getQuery() ->accessCheck(${TRUE}) $0 - ->execute()"#, + ->execute()"# + .to_string(), + ); + snippets.insert("type".to_string(), "'#type' => '$0',".to_string()); + snippets.insert( + "title".to_string(), + "'#title' => \\$this->t('$0'),".to_string(), + ); + snippets.insert( + "description".to_string(), + "'#description' => \\$this->t('$0'),".to_string(), + ); + snippets.insert( + "attributes".to_string(), + "'#attributes' => [$0],".to_string(), ); - snippets.insert("type", "'#type' => '$0',"); - snippets.insert("title", "'#title' => \\$this->t('$0'),"); - snippets.insert("description", "'#description' => \\$this->t('$0'),"); - snippets.insert("attributes", "'#attributes' => [$0],"); snippets.insert( - "attributes-class", - "'#attributes' => [\n 'class' => ['$0'],\n],", + "attributes-class".to_string(), + "'#attributes' => [\n 'class' => ['$0'],\n],".to_string(), ); - snippets.insert("attributes-id", "'#attributes' => [\n 'id' => '$0',\n],"); snippets.insert( - "type_html_tag", + "attributes-id".to_string(), + "'#attributes' => [\n 'id' => '$0',\n],".to_string(), + ); + snippets.insert( + "type_html_tag".to_string(), r#"'#type' => 'html_tag', '#tag' => '$1', -'#value' => $0,"#, +'#value' => $0,"# + .to_string(), ); snippets.insert( - "type_details", + "type_details".to_string(), r#"'#type' => 'details', '#open' => TRUE, -'#title' => \$this->t('$0'),"#, +'#title' => \$this->t('$0'),"# + .to_string(), ); snippets.insert( - "create", + "create".to_string(), r#"/** * {@inheritdoc} */ @@ -406,10 +422,11 @@ public static function create(ContainerInterface \$container) { return new static( \$container->get('$0'), ); -}"#, +}"# + .to_string(), ); snippets.insert( - "create-plugin", + "create-plugin".to_string(), r#"/** * {@inheritdoc} */ @@ -420,9 +437,42 @@ public static function create(ContainerInterface \$container, array \$configurat \$plugin_definition, \$container->get('$0'), ); -}"#, +}"#.to_string(), ); + // Create pre-generated snippets. + DOCUMENT_STORE + .lock() + .unwrap() + .get_documents() + .values() + .for_each(|document| { + document.tokens.iter().for_each(|token| match &token.data { + TokenData::PhpClassDefinition(class_definition) => { + if let Some(attribute) = &class_definition.attribute { + match attribute { + ClassAttribute::Plugin(plugin) => match plugin.plugin_type { + DrupalPluginType::RenderElement | DrupalPluginType::FormElement => { + if let Some(usage_example) = &plugin.usage_example { + let mut snippet_key = "render"; + if plugin.plugin_type == DrupalPluginType::FormElement { + snippet_key = "form"; + } + snippets.insert( + format!("{}-{}", snippet_key, plugin.plugin_id) + .to_string(), + usage_example.replace("$", "\\$"), + ); + } + } + _ => {} + }, + } + } + } + _ => {} + }) + }); snippets .iter() .map(|(name, snippet)| CompletionItem { From 4f5eb154410efc1b5e773fb202c4642a934691af Mon Sep 17 00:00:00 2001 From: n1kolas Date: Tue, 22 Apr 2025 20:26:10 +0200 Subject: [PATCH 3/7] style: improve snippet generation loop --- src/server/handlers/completion/mod.rs | 52 ++++++++++++++------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/src/server/handlers/completion/mod.rs b/src/server/handlers/completion/mod.rs index f8bec89..9207be2 100644 --- a/src/server/handlers/completion/mod.rs +++ b/src/server/handlers/completion/mod.rs @@ -446,33 +446,35 @@ public static function create(ContainerInterface \$container, array \$configurat .unwrap() .get_documents() .values() - .for_each(|document| { - document.tokens.iter().for_each(|token| match &token.data { - TokenData::PhpClassDefinition(class_definition) => { - if let Some(attribute) = &class_definition.attribute { - match attribute { - ClassAttribute::Plugin(plugin) => match plugin.plugin_type { - DrupalPluginType::RenderElement | DrupalPluginType::FormElement => { - if let Some(usage_example) = &plugin.usage_example { - let mut snippet_key = "render"; - if plugin.plugin_type == DrupalPluginType::FormElement { - snippet_key = "form"; - } - snippets.insert( - format!("{}-{}", snippet_key, plugin.plugin_id) - .to_string(), - usage_example.replace("$", "\\$"), - ); - } - } - _ => {} - }, - } - } - } - _ => {} + .flat_map(|document| document.tokens.iter()) + .filter_map(|token| match &token.data { + TokenData::PhpClassDefinition(class_def) => match &class_def.attribute { + Some(ClassAttribute::Plugin(plugin)) => Some(plugin), + _ => None, + }, + _ => None, + }) + .filter_map(|plugin| { + let snippet_key_prefix = match plugin.plugin_type { + DrupalPluginType::RenderElement => Some("render"), + DrupalPluginType::FormElement => Some("form"), + _ => None, + }; + + snippet_key_prefix.and_then(|prefix| { + plugin + .usage_example + .as_ref() + .map(|usage_example| (prefix, &plugin.plugin_id, usage_example)) }) + }) + .for_each(|(snippet_key_prefix, plugin_id, usage_example)| { + snippets.insert( + format!("{}-{}", snippet_key_prefix, plugin_id), + usage_example.replace("$", "\\$"), + ); }); + snippets .iter() .map(|(name, snippet)| CompletionItem { From 89257a7eae1337fccf55bff782a4b9b7b2177366 Mon Sep 17 00:00:00 2001 From: n1kolas Date: Wed, 23 Apr 2025 14:58:13 +0200 Subject: [PATCH 4/7] style: provide usage_example inline --- src/parser/php.rs | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/parser/php.rs b/src/parser/php.rs index 5dbce71..e2e87e8 100644 --- a/src/parser/php.rs +++ b/src/parser/php.rs @@ -274,26 +274,12 @@ impl PhpParser { class_attribute = Some(ClassAttribute::Plugin(DrupalPlugin { plugin_type, plugin_id, - usage_example: None, + usage_example: self.extract_usage_example_from_comment(&comment_node), })); }; } } - // Try to get an usage example for snippet generation. - if let Some(attribute) = &mut class_attribute { - match attribute { - ClassAttribute::Plugin(ref mut drupal_plugin) => { - if let Some(prev_sibling) = node.prev_named_sibling() { - if prev_sibling.kind() == "comment" { - drupal_plugin.usage_example = - self.extract_usage_example_from_comment(&prev_sibling); - } - } - } - } - } - Some(Token::new( TokenData::PhpClassDefinition(PhpClass { name: self.get_class_name_from_node(node)?, @@ -355,7 +341,9 @@ impl PhpParser { Ok(plugin_type) => Some(ClassAttribute::Plugin(DrupalPlugin { plugin_id, plugin_type, - usage_example: None, + usage_example: self.extract_usage_example_from_comment( + &node.parent()?.parent()?.parent()?.prev_named_sibling()?, + ), })), Err(_) => None, } From e36c53043d6b5128e9adbf08c805b1a2004bb111 Mon Sep 17 00:00:00 2001 From: n1kolas Date: Wed, 23 Apr 2025 14:58:34 +0200 Subject: [PATCH 5/7] feat: check for comment node kind in usage example extraction --- src/parser/php.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/parser/php.rs b/src/parser/php.rs index e2e87e8..a1df873 100644 --- a/src/parser/php.rs +++ b/src/parser/php.rs @@ -377,6 +377,10 @@ impl PhpParser { /// Helper function to extract usage example from the preceding comment fn extract_usage_example_from_comment(&self, comment_node: &Node) -> Option { + if comment_node.kind() != "comment" { + return None; + } + let comment_text = self.get_node_text(comment_node); let start_tag = "@code"; let end_tag = "@endcode"; From b5f99b8ae3df2495d75f45427a0c53399c5e6571 Mon Sep 17 00:00:00 2001 From: n1kolas Date: Wed, 23 Apr 2025 15:40:50 +0200 Subject: [PATCH 6/7] style: use Box::leak to keep hashmap snippet structure --- src/server/handlers/completion/mod.rs | 84 +++++++++++---------------- 1 file changed, 34 insertions(+), 50 deletions(-) diff --git a/src/server/handlers/completion/mod.rs b/src/server/handlers/completion/mod.rs index 9207be2..e33ebd0 100644 --- a/src/server/handlers/completion/mod.rs +++ b/src/server/handlers/completion/mod.rs @@ -326,9 +326,9 @@ pub fn handle_text_document_completion(request: Request) -> Option { } fn get_global_snippets() -> Vec { - let mut snippets: HashMap = HashMap::new(); + let mut snippets = HashMap::new(); snippets.insert( - "batch".to_string(), + "batch", r#" \$storage = \\Drupal::entityTypeManager()->getStorage('$0'); if (!isset(\$sandbox['ids'])) { @@ -346,75 +346,58 @@ foreach (\$storage->loadMultiple(\$ids) as \$entity) { if (\$sandbox['total'] > 0) { \$sandbox['#finished'] = (\$sandbox['total'] - count(\$sandbox['ids'])) / \$sandbox['total']; -}"# - .to_string(), +}"#, ); snippets.insert( - "ihdoc".to_string(), + "ihdoc", r#" /** * {@inheritdoc} - */"# - .to_string(), + */"#, ); snippets.insert( - "ensure-instanceof".to_string(), - "if (!($1 instanceof $2)) {\n return$0;\n}".to_string(), + "ensure-instanceof", + "if (!($1 instanceof $2)) {\n return$0;\n}", ); snippets.insert( - "entity-storage".to_string(), - "\\$storage = \\$this->entityTypeManager->getStorage('$0');".to_string(), + "entity-storage", + "\\$storage = \\$this->entityTypeManager->getStorage('$0');", ); snippets.insert( - "entity-load".to_string(), - "\\$$1 = \\$this->entityTypeManager->getStorage('$1')->load($0);".to_string(), + "entity-load", + "\\$$1 = \\$this->entityTypeManager->getStorage('$1')->load($0);", ); snippets.insert( - "entity-query".to_string(), + "entity-query", r#" \$ids = \$this->entityTypeManager->getStorage('$1')->getQuery() ->accessCheck(${TRUE}) $0 - ->execute()"# - .to_string(), + ->execute()"#, ); - snippets.insert("type".to_string(), "'#type' => '$0',".to_string()); + snippets.insert("type", "'#type' => '$0',"); + snippets.insert("title", "'#title' => \\$this->t('$0'),"); + snippets.insert("description", "'#description' => \\$this->t('$0'),"); + snippets.insert("attributes", "'#attributes' => [$0],"); snippets.insert( - "title".to_string(), - "'#title' => \\$this->t('$0'),".to_string(), + "attributes-class", + "'#attributes' => [\n 'class' => ['$0'],\n],", ); + snippets.insert("attributes-id", "'#attributes' => [\n 'id' => '$0',\n],"); snippets.insert( - "description".to_string(), - "'#description' => \\$this->t('$0'),".to_string(), - ); - snippets.insert( - "attributes".to_string(), - "'#attributes' => [$0],".to_string(), - ); - snippets.insert( - "attributes-class".to_string(), - "'#attributes' => [\n 'class' => ['$0'],\n],".to_string(), - ); - snippets.insert( - "attributes-id".to_string(), - "'#attributes' => [\n 'id' => '$0',\n],".to_string(), - ); - snippets.insert( - "type_html_tag".to_string(), + "type_html_tag", r#"'#type' => 'html_tag', '#tag' => '$1', -'#value' => $0,"# - .to_string(), +'#value' => $0,"#, ); snippets.insert( - "type_details".to_string(), + "type_details", r#"'#type' => 'details', '#open' => TRUE, -'#title' => \$this->t('$0'),"# - .to_string(), +'#title' => \$this->t('$0'),"#, ); snippets.insert( - "create".to_string(), + "create", r#"/** * {@inheritdoc} */ @@ -422,11 +405,10 @@ public static function create(ContainerInterface \$container) { return new static( \$container->get('$0'), ); -}"# - .to_string(), +}"#, ); snippets.insert( - "create-plugin".to_string(), + "create-plugin", r#"/** * {@inheritdoc} */ @@ -437,7 +419,7 @@ public static function create(ContainerInterface \$container, array \$configurat \$plugin_definition, \$container->get('$0'), ); -}"#.to_string(), +}"#, ); // Create pre-generated snippets. @@ -469,10 +451,12 @@ public static function create(ContainerInterface \$container, array \$configurat }) }) .for_each(|(snippet_key_prefix, plugin_id, usage_example)| { - snippets.insert( - format!("{}-{}", snippet_key_prefix, plugin_id), - usage_example.replace("$", "\\$"), - ); + let key_string: String = format!("{}-{}", snippet_key_prefix, plugin_id); + let value_string: String = usage_example.replace("$", "\\$"); + + let key: &'static str = Box::leak(key_string.into_boxed_str()); + let value: &'static str = Box::leak(value_string.into_boxed_str()); + snippets.insert(key, value); }); snippets From d78afe6618e4eece4a0b397476bf535e9af00c43 Mon Sep 17 00:00:00 2001 From: jdrupal-dev <13871894+jdrupal-dev@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:47:07 +0200 Subject: [PATCH 7/7] refactor: revert back to using Strings for snippet generation --- src/server/handlers/completion/mod.rs | 53 ++++++++++++++------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/server/handlers/completion/mod.rs b/src/server/handlers/completion/mod.rs index e33ebd0..92b0854 100644 --- a/src/server/handlers/completion/mod.rs +++ b/src/server/handlers/completion/mod.rs @@ -326,8 +326,13 @@ pub fn handle_text_document_completion(request: Request) -> Option { } fn get_global_snippets() -> Vec { - let mut snippets = HashMap::new(); - snippets.insert( + let mut snippets: HashMap = HashMap::new(); + + let mut add_snippet = |key: &str, value: &str| { + snippets.insert(key.into(), value.into()); + }; + + add_snippet( "batch", r#" \$storage = \\Drupal::entityTypeManager()->getStorage('$0'); @@ -348,26 +353,26 @@ if (\$sandbox['total'] > 0) { \$sandbox['#finished'] = (\$sandbox['total'] - count(\$sandbox['ids'])) / \$sandbox['total']; }"#, ); - snippets.insert( + add_snippet( "ihdoc", r#" /** * {@inheritdoc} */"#, ); - snippets.insert( + add_snippet( "ensure-instanceof", "if (!($1 instanceof $2)) {\n return$0;\n}", ); - snippets.insert( + add_snippet( "entity-storage", "\\$storage = \\$this->entityTypeManager->getStorage('$0');", ); - snippets.insert( + add_snippet( "entity-load", "\\$$1 = \\$this->entityTypeManager->getStorage('$1')->load($0);", ); - snippets.insert( + add_snippet( "entity-query", r#" \$ids = \$this->entityTypeManager->getStorage('$1')->getQuery() @@ -375,28 +380,28 @@ if (\$sandbox['total'] > 0) { $0 ->execute()"#, ); - snippets.insert("type", "'#type' => '$0',"); - snippets.insert("title", "'#title' => \\$this->t('$0'),"); - snippets.insert("description", "'#description' => \\$this->t('$0'),"); - snippets.insert("attributes", "'#attributes' => [$0],"); - snippets.insert( + add_snippet("type", "'#type' => '$0',"); + add_snippet("title", "'#title' => \\$this->t('$0'),"); + add_snippet("description", "'#description' => \\$this->t('$0'),"); + add_snippet("attributes", "'#attributes' => [$0],"); + add_snippet( "attributes-class", "'#attributes' => [\n 'class' => ['$0'],\n],", ); - snippets.insert("attributes-id", "'#attributes' => [\n 'id' => '$0',\n],"); - snippets.insert( + add_snippet("attributes-id", "'#attributes' => [\n 'id' => '$0',\n],"); + add_snippet( "type_html_tag", r#"'#type' => 'html_tag', '#tag' => '$1', '#value' => $0,"#, ); - snippets.insert( + add_snippet( "type_details", r#"'#type' => 'details', '#open' => TRUE, '#title' => \$this->t('$0'),"#, ); - snippets.insert( + add_snippet( "create", r#"/** * {@inheritdoc} @@ -407,7 +412,7 @@ public static function create(ContainerInterface \$container) { ); }"#, ); - snippets.insert( + add_snippet( "create-plugin", r#"/** * {@inheritdoc} @@ -451,20 +456,18 @@ public static function create(ContainerInterface \$container, array \$configurat }) }) .for_each(|(snippet_key_prefix, plugin_id, usage_example)| { - let key_string: String = format!("{}-{}", snippet_key_prefix, plugin_id); - let value_string: String = usage_example.replace("$", "\\$"); - - let key: &'static str = Box::leak(key_string.into_boxed_str()); - let value: &'static str = Box::leak(value_string.into_boxed_str()); - snippets.insert(key, value); + snippets.insert( + format!("{}-{}", snippet_key_prefix, plugin_id), + usage_example.replace("$", "\\$"), + ); }); snippets .iter() .map(|(name, snippet)| CompletionItem { - label: name.to_string(), + label: name.clone(), kind: Some(CompletionItemKind::SNIPPET), - insert_text: Some(snippet.to_string()), + insert_text: Some(snippet.clone()), insert_text_format: Some(InsertTextFormat::SNIPPET), deprecated: Some(false), ..CompletionItem::default()