From 9ceab4d063f2433f7c0c1aa34c77be36f3e669ce Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 31 May 2026 08:05:22 +0100 Subject: [PATCH 1/8] feat: add color autocomplete info --- .../src/db_index/type/types.rs | 1 + .../add_completions/add_decl_completion.rs | 87 +++++- .../add_completions/add_member_completion.rs | 91 +++++- .../add_completions/completion_item_info.rs | 121 ++++++++ .../completion/add_completions/mod.rs | 1 + .../handlers/completion/completion_data.rs | 33 +++ .../handlers/completion/resolve_completion.rs | 43 ++- .../handlers/document_color/build_color.rs | 128 +++++--- .../src/handlers/document_color/mod.rs | 2 +- .../glua_ls/src/handlers/request_handler.rs | 1 + .../src/handlers/test/completion_test.rs | 278 +++++++++++++++++- 11 files changed, 713 insertions(+), 73 deletions(-) create mode 100644 crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs diff --git a/crates/glua_code_analysis/src/db_index/type/types.rs b/crates/glua_code_analysis/src/db_index/type/types.rs index b4dd04d4..bc434a6e 100644 --- a/crates/glua_code_analysis/src/db_index/type/types.rs +++ b/crates/glua_code_analysis/src/db_index/type/types.rs @@ -420,6 +420,7 @@ impl LuaType { | LuaType::TableConst(_) | LuaType::DocStringConst(_) | LuaType::DocIntegerConst(_) + | LuaType::DocBooleanConst(_) ) } diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs index f2a4b618..777575ba 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs @@ -1,4 +1,5 @@ use glua_code_analysis::{DbIndex, LuaDeclId, LuaSemanticDeclId, LuaType}; +use glua_parser::{LuaAstNode, LuaExpr, LuaSyntaxKind}; use lsp_types::CompletionItem; use crate::handlers::completion::{ @@ -7,7 +8,12 @@ use crate::handlers::completion::{ }; use super::{ - CallDisplay, check_visibility, get_completion_kind, get_description, get_detail, is_deprecated, + CallDisplay, check_visibility, + completion_item_info::{ + color_info_from_expr, color_info_from_type, is_color_type, scalar_literal_description, + scalar_literal_detail, + }, + get_completion_kind, get_description, get_detail, is_deprecated, }; pub fn add_decl_completion( @@ -20,14 +26,36 @@ pub fn add_decl_completion( check_visibility(builder, property_owner.clone())?; let overload_count = count_function_overloads(builder.semantic_model.get_db(), typ); + let color = get_decl_completion_color(builder, decl_id, typ); + let literal_detail = scalar_literal_detail(typ); let mut completion_item = CompletionItem { label: name.to_string(), - kind: Some(get_completion_kind(typ)), - data: CompletionData::from_property_owner_id(builder, decl_id.into(), overload_count), + kind: Some(if color.is_some() { + lsp_types::CompletionItemKind::COLOR + } else { + get_completion_kind(typ) + }), + data: match color.clone() { + Some(color) => CompletionData::from_property_owner_id_with_color( + builder, + decl_id.into(), + overload_count, + color, + ), + None => CompletionData::from_property_owner_id(builder, decl_id.into(), overload_count), + }, label_details: Some(lsp_types::CompletionItemLabelDetails { - detail: get_detail(builder, typ, CallDisplay::None), - description: get_description(builder, typ), + detail: color + .as_ref() + .map(|color| format!(" {}", color.hex)) + .or_else(|| get_detail(builder, typ, CallDisplay::None)) + .or(literal_detail), + description: if color.is_some() { + Some("Color".to_string()) + } else { + scalar_literal_description(typ).or_else(|| get_description(builder, typ)) + }, }), ..Default::default() }; @@ -66,3 +94,52 @@ fn count_function_overloads(db: &DbIndex, typ: &LuaType) -> Option { } if count == 0 { None } else { Some(count) } } + +fn get_decl_completion_color( + builder: &CompletionBuilder, + decl_id: LuaDeclId, + typ: &LuaType, +) -> Option { + if let Some(color) = color_info_from_type(typ) { + return Some(color); + } + + if !is_color_type(typ) && !matches!(typ, LuaType::Unknown) { + return None; + } + + let value_expr = get_decl_value_expr(builder, decl_id)?; + color_info_from_expr(&value_expr) +} + +fn get_decl_value_expr(builder: &CompletionBuilder, decl_id: LuaDeclId) -> Option { + let decl = builder + .semantic_model + .get_db() + .get_decl_index() + .get_decl(&decl_id)?; + let value_syntax_id = decl.get_value_syntax_id()?; + if !can_expr_syntax_be_color(value_syntax_id.get_kind()) { + return None; + } + let tree = builder + .semantic_model + .get_db() + .get_vfs() + .get_syntax_tree(&decl_id.file_id)?; + let value_node = value_syntax_id.to_node_from_root(&tree.get_red_root())?; + LuaExpr::cast(value_node) +} + +fn can_expr_syntax_be_color(kind: LuaSyntaxKind) -> bool { + matches!( + kind, + LuaSyntaxKind::CallExpr + | LuaSyntaxKind::LiteralExpr + | LuaSyntaxKind::RequireCallExpr + | LuaSyntaxKind::AssertCallExpr + | LuaSyntaxKind::ErrorCallExpr + | LuaSyntaxKind::TypeCallExpr + | LuaSyntaxKind::SetmetatableCallExpr + ) +} diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs index c752bbd4..c42691ab 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs @@ -1,22 +1,27 @@ use glua_code_analysis::{ - DbIndex, LuaAliasCallKind, LuaMemberInfo, LuaMemberKey, LuaSemanticDeclId, LuaType, - SemanticModel, get_keyof_members, try_extract_signature_id_from_field, + DbIndex, LuaAliasCallKind, LuaMemberId, LuaMemberInfo, LuaMemberKey, LuaSemanticDeclId, + LuaType, SemanticModel, get_keyof_members, try_extract_signature_id_from_field, }; use glua_parser::{ - LuaAssignStat, LuaAstNode, LuaAstToken, LuaFuncStat, LuaGeneralToken, LuaIndexExpr, - LuaParenExpr, LuaTokenKind, + LuaAssignStat, LuaAstNode, LuaAstToken, LuaExpr, LuaFuncStat, LuaGeneralToken, LuaIndexExpr, + LuaParenExpr, LuaTableField, LuaTokenKind, }; use lsp_types::CompletionItem; use crate::handlers::completion::{ add_completions::get_function_snippet, completion_builder::CompletionBuilder, - completion_data::CompletionData, + completion_data::{CompletionColorInfo, CompletionData}, providers::{apply_staged_call_snippet, get_function_remove_nil}, }; use super::{ - CallDisplay, check_visibility, get_completion_kind, get_description, get_detail, is_deprecated, + CallDisplay, check_visibility, + completion_item_info::{ + color_info_from_expr, color_info_from_type, is_color_type, scalar_literal_description, + scalar_literal_detail, + }, + get_completion_kind, get_description, get_detail, is_deprecated, }; #[derive(Debug, Clone, Copy, PartialEq)] @@ -96,11 +101,19 @@ pub fn add_member_completion( if status == CompletionTriggerStatus::Colon && !remove_nil_type.is_function() { return None; } + let color = get_member_completion_color(builder, property_owner, &remove_nil_type); // 附加数据, 用于在`resolve`时进一步处理 let completion_data = if let Some(id) = &property_owner { if let Some(index) = member_info.overload_index { CompletionData::from_overload(builder, id.clone(), index, overload_count) + } else if let Some(color) = color.clone() { + CompletionData::from_property_owner_id_with_color( + builder, + id.clone(), + overload_count, + color, + ) } else { CompletionData::from_property_owner_id(builder, id.clone(), overload_count) } @@ -112,16 +125,22 @@ pub fn add_member_completion( .unwrap_or(CallDisplay::None); let is_inferred_dynamic_member = property_owner.is_none(); // 紧靠着 label 显示的描述 - let detail = if is_inferred_dynamic_member { + let literal_detail = scalar_literal_detail(&remove_nil_type); + let detail = if let Some(color) = &color { + Some(format!(" {}", color.hex)) + } else if is_inferred_dynamic_member { None } else { - get_detail(builder, &remove_nil_type, call_display) + get_detail(builder, &remove_nil_type, call_display).or(literal_detail) }; // 在`detail`更右侧, 且不紧靠着`detail`显示 - let description = if is_inferred_dynamic_member { + let description = if color.is_some() { + Some("Color".to_string()) + } else if is_inferred_dynamic_member { None } else { - get_description(builder, &remove_nil_type) + scalar_literal_description(&remove_nil_type) + .or_else(|| get_description(builder, &remove_nil_type)) }; let deprecated = property_owner @@ -130,7 +149,9 @@ pub fn add_member_completion( let mut completion_item = CompletionItem { label: label.clone(), - kind: Some(if is_inferred_dynamic_member { + kind: Some(if color.is_some() { + lsp_types::CompletionItemKind::COLOR + } else if is_inferred_dynamic_member { lsp_types::CompletionItemKind::VARIABLE } else { get_completion_kind(&remove_nil_type) @@ -202,6 +223,54 @@ pub fn add_member_completion( Some(()) } +fn get_member_completion_color( + builder: &CompletionBuilder, + property_owner: &Option, + typ: &LuaType, +) -> Option { + if let Some(color) = color_info_from_type(typ) { + return Some(color); + } + + if !is_color_type(typ) && !matches!(typ, LuaType::Unknown) { + return None; + } + + let LuaSemanticDeclId::Member(member_id) = property_owner.as_ref()? else { + return None; + }; + let value_expr = get_member_value_expr(builder.semantic_model.get_db(), *member_id)?; + color_info_from_expr(&value_expr) +} + +fn get_member_value_expr(db: &DbIndex, member_id: LuaMemberId) -> Option { + let root = db + .get_vfs() + .get_syntax_tree(&member_id.file_id)? + .get_red_root(); + let node = member_id.get_syntax_id().to_node_from_root(&root)?; + + if let Some(field) = LuaTableField::cast(node.clone()) { + return field.get_value_expr(); + } + + if let Some(index_expr) = LuaIndexExpr::cast(node) { + if let Some(assign_stat) = index_expr.get_parent::() { + let (vars, value_exprs) = assign_stat.get_var_and_expr_list(); + let value_idx = vars + .iter() + .position(|var| var.get_syntax_id() == index_expr.get_syntax_id())?; + return value_exprs.get(value_idx).cloned(); + } + + if let Some(func_stat) = index_expr.get_parent::() { + return func_stat.get_closure().map(LuaExpr::ClosureExpr); + } + } + + None +} + fn add_signature_overloads( builder: &mut CompletionBuilder, property_owner: &Option, diff --git a/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs b/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs new file mode 100644 index 00000000..83a8d1a9 --- /dev/null +++ b/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs @@ -0,0 +1,121 @@ +use glua_code_analysis::{LuaType, LuaUnionType}; +use glua_parser::{LuaExpr, LuaLiteralToken}; + +use crate::handlers::{ + completion::completion_data::CompletionColorInfo, + document_color::build_color::{ + GmodColor, gmod_color_from_call_expr, gmod_hex_color_from_hex_text, + }, +}; + +pub(super) fn color_info_from_type(typ: &LuaType) -> Option { + let text = match typ { + LuaType::StringConst(text) | LuaType::DocStringConst(text) => text.as_str(), + _ => return None, + }; + + // Avoid reclassifying arbitrary 6-character ids as colors. In completions, + // hex string constants are treated as colors only when they use color-style syntax. + if !text.starts_with('#') { + return None; + } + + let hex_color = gmod_hex_color_from_hex_text(text)?; + Some(completion_color_info(hex_color.color, hex_color.has_alpha)) +} + +pub(super) fn color_info_from_expr(expr: &LuaExpr) -> Option { + match expr { + LuaExpr::CallExpr(call_expr) => { + let color = gmod_color_from_call_expr(call_expr)?; + let alpha = (color.alpha * 255.0).round() as u8; + Some(completion_color_info(color, alpha != u8::MAX)) + } + LuaExpr::LiteralExpr(literal_expr) => { + let LuaLiteralToken::String(token) = literal_expr.get_literal()? else { + return None; + }; + let text = token.get_value(); + if !text.starts_with('#') { + return None; + } + let hex_color = gmod_hex_color_from_hex_text(&text)?; + Some(completion_color_info(hex_color.color, hex_color.has_alpha)) + } + _ => None, + } +} + +pub(super) fn scalar_literal_detail(typ: &LuaType) -> Option { + let value = match typ { + LuaType::BooleanConst(value) | LuaType::DocBooleanConst(value) => value.to_string(), + LuaType::IntegerConst(value) | LuaType::DocIntegerConst(value) => value.to_string(), + LuaType::FloatConst(value) => value.to_string(), + LuaType::StringConst(value) | LuaType::DocStringConst(value) => { + format!("{:?}", value.as_str()) + } + _ => return None, + }; + + Some(format!(" = {}", truncate_literal_value(&value))) +} + +pub(super) fn scalar_literal_description(typ: &LuaType) -> Option { + match typ { + LuaType::BooleanConst(_) | LuaType::DocBooleanConst(_) => Some("boolean".to_string()), + LuaType::IntegerConst(_) | LuaType::DocIntegerConst(_) => Some("integer".to_string()), + LuaType::FloatConst(_) => Some("number".to_string()), + LuaType::StringConst(_) | LuaType::DocStringConst(_) => Some("string".to_string()), + _ => None, + } +} + +pub(super) fn is_color_type(typ: &LuaType) -> bool { + match typ { + LuaType::Ref(id) | LuaType::Def(id) => id.get_simple_name() == "Color", + LuaType::Instance(instance) => is_color_type(instance.get_base()), + LuaType::Union(union) => match union.as_ref() { + LuaUnionType::Nullable(typ) => is_color_type(typ), + LuaUnionType::Multi(types) => types.iter().any(is_color_type), + }, + LuaType::Intersection(intersection) => intersection.get_types().iter().any(is_color_type), + _ => false, + } +} + +fn completion_color_info(color: GmodColor, include_alpha_in_hex: bool) -> CompletionColorInfo { + let red = (color.red * 255.0).round() as u8; + let green = (color.green * 255.0).round() as u8; + let blue = (color.blue * 255.0).round() as u8; + let alpha = (color.alpha * 255.0).round() as u8; + let hex = if include_alpha_in_hex { + format!("#{:02X}{:02X}{:02X}{:02X}", red, green, blue, alpha) + } else { + format!("#{:02X}{:02X}{:02X}", red, green, blue) + }; + + CompletionColorInfo { + red, + green, + blue, + alpha, + rgb: format!("rgb({}, {}, {})", red, green, blue), + rgba: format!("rgba({}, {}, {}, {})", red, green, blue, alpha), + gmod: format!("Color({}, {}, {}, {})", red, green, blue, alpha), + hex, + } +} + +fn truncate_literal_value(value: &str) -> String { + const MAX_LITERAL_DETAIL_CHARS: usize = 80; + if value.chars().count() <= MAX_LITERAL_DETAIL_CHARS { + return value.to_string(); + } + + let mut truncated = value + .chars() + .take(MAX_LITERAL_DETAIL_CHARS.saturating_sub(3)) + .collect::(); + truncated.push_str("..."); + truncated +} diff --git a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs index 1bb45be9..388dd3cb 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs @@ -1,6 +1,7 @@ mod add_decl_completion; mod add_member_completion; mod check_match_word; +mod completion_item_info; pub use add_decl_completion::add_decl_completion; pub use add_member_completion::get_index_alias_name; diff --git a/crates/glua_ls/src/handlers/completion/completion_data.rs b/crates/glua_ls/src/handlers/completion/completion_data.rs index bf5d0257..6e3473f8 100644 --- a/crates/glua_ls/src/handlers/completion/completion_data.rs +++ b/crates/glua_ls/src/handlers/completion/completion_data.rs @@ -13,6 +13,20 @@ pub struct CompletionData { pub typ: CompletionDataType, /// Total count of function overloads pub overload_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompletionColorInfo { + pub red: u8, + pub green: u8, + pub blue: u8, + pub alpha: u8, + pub hex: String, + pub rgb: String, + pub rgba: String, + pub gmod: String, } #[allow(unused)] @@ -27,6 +41,23 @@ impl CompletionData { uri: Some(builder.semantic_model.get_document().get_uri().clone()), typ: CompletionDataType::PropertyOwnerId(id), overload_count, + color: None, + }; + Some(serde_json::to_value(data).unwrap()) + } + + pub fn from_property_owner_id_with_color( + builder: &CompletionBuilder, + id: LuaSemanticDeclId, + overload_count: Option, + color: CompletionColorInfo, + ) -> Option { + let data = Self { + field_id: builder.semantic_model.get_file_id(), + uri: Some(builder.semantic_model.get_document().get_uri().clone()), + typ: CompletionDataType::PropertyOwnerId(id), + overload_count, + color: Some(color), }; Some(serde_json::to_value(data).unwrap()) } @@ -42,6 +73,7 @@ impl CompletionData { uri: Some(builder.semantic_model.get_document().get_uri().clone()), typ: CompletionDataType::Overload((id, index)), overload_count, + color: None, }; Some(serde_json::to_value(data).unwrap()) } @@ -52,6 +84,7 @@ impl CompletionData { uri: Some(builder.semantic_model.get_document().get_uri().clone()), typ: CompletionDataType::Module(module), overload_count: None, + color: None, }; Some(serde_json::to_value(data).unwrap()) } diff --git a/crates/glua_ls/src/handlers/completion/resolve_completion.rs b/crates/glua_ls/src/handlers/completion/resolve_completion.rs index 11bebeaa..22d48706 100644 --- a/crates/glua_ls/src/handlers/completion/resolve_completion.rs +++ b/crates/glua_ls/src/handlers/completion/resolve_completion.rs @@ -6,7 +6,7 @@ use crate::{ handlers::hover::{HoverBuilder, build_hover_content_for_completion}, }; -use super::completion_data::{CompletionData, CompletionDataType}; +use super::completion_data::{CompletionColorInfo, CompletionData, CompletionDataType}; pub fn resolve_completion( compilation: &LuaCompilation, @@ -17,6 +17,7 @@ pub fn resolve_completion( client_id: ClientId, ) -> Option<()> { // todo: resolve completion + let color = completion_data.color.clone(); match completion_data.typ { CompletionDataType::PropertyOwnerId(property_id) => { let hover_builder = @@ -44,6 +45,9 @@ pub fn resolve_completion( } _ => {} } + if let Some(color) = color { + apply_color_completion_documentation(completion_item, &color); + } Some(()) } @@ -187,3 +191,40 @@ fn build_other_completion_item( })); Some(()) } + +fn apply_color_completion_documentation( + completion_item: &mut CompletionItem, + color: &CompletionColorInfo, +) { + let mut color_documentation = String::new(); + color_documentation.push_str("\n\n---\n\n"); + color_documentation.push_str("**Color preview**\n\n"); + color_documentation.push_str(&format!( + " {}\n\n", + color.hex, color.hex + )); + color_documentation.push_str(&format!( + "`{}` \n`{}` \n`{}`", + color.rgb, color.rgba, color.gmod + )); + + match completion_item.documentation.take() { + Some(Documentation::MarkupContent(mut markup)) => { + markup.value.push_str(&color_documentation); + completion_item.documentation = Some(Documentation::MarkupContent(markup)); + } + Some(Documentation::String(mut text)) => { + text.push_str(&color_documentation); + completion_item.documentation = Some(Documentation::MarkupContent(MarkupContent { + kind: lsp_types::MarkupKind::Markdown, + value: text, + })); + } + None => { + completion_item.documentation = Some(Documentation::MarkupContent(MarkupContent { + kind: lsp_types::MarkupKind::Markdown, + value: color_documentation.trim_start().to_string(), + })); + } + } +} diff --git a/crates/glua_ls/src/handlers/document_color/build_color.rs b/crates/glua_ls/src/handlers/document_color/build_color.rs index 2ceff9b2..79864b98 100644 --- a/crates/glua_ls/src/handlers/document_color/build_color.rs +++ b/crates/glua_ls/src/handlers/document_color/build_color.rs @@ -6,6 +6,30 @@ use glua_parser::{ use lsp_types::{Color, ColorInformation}; use rowan::{TextRange, TextSize}; +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct GmodColor { + pub(crate) red: f32, + pub(crate) green: f32, + pub(crate) blue: f32, + pub(crate) alpha: f32, +} + +pub(crate) struct GmodHexColor { + pub(crate) color: GmodColor, + pub(crate) has_alpha: bool, +} + +impl GmodColor { + pub(crate) fn to_lsp_color(self) -> Color { + Color { + red: self.red, + green: self.green, + blue: self.blue, + alpha: self.alpha, + } + } +} + pub fn build_colors( root: LuaSyntaxNode, document: &LuaDocument, @@ -165,6 +189,30 @@ fn try_build_gmod_color_call( document: &LuaDocument, result: &mut Vec, ) -> Option<()> { + let (color, args) = gmod_color_call_info(&call_expr)?; + + // Use the range of the arguments only (not the whole call expr) so the swatch + // appears inside the brackets, consistent with other color-tuple detections. + let first_arg = args.first()?; + let last_arg = args.last()?; + let args_range = TextRange::new( + first_arg.syntax().text_range().start(), + last_arg.syntax().text_range().end(), + ); + let range = document.to_lsp_range(args_range)?; + result.push(ColorInformation { + range, + color: color.to_lsp_color(), + }); + + Some(()) +} + +pub(crate) fn gmod_color_from_call_expr(call_expr: &LuaCallExpr) -> Option { + gmod_color_call_info(call_expr).map(|(color, _)| color) +} + +fn gmod_color_call_info(call_expr: &LuaCallExpr) -> Option<(GmodColor, Vec)> { // Prefix must be a bare name expression "Color". let prefix = call_expr.get_prefix_expr()?; let LuaExpr::NameExpr(name_expr) = &prefix else { @@ -173,7 +221,7 @@ fn try_build_gmod_color_call( let name_token = name_expr.get_name_token()?; if name_token.get_name_text() != "Color" { return None; - } + }; let args_list = call_expr.get_args_list()?; let args: Vec<_> = args_list.get_args().collect(); @@ -203,26 +251,15 @@ fn try_build_gmod_color_call( components[i] = (value / 255.0) as f32; } - // Use the range of the arguments only (not the whole call expr) so the swatch - // appears inside the brackets, consistent with other color-tuple detections. - let first_arg = args.first()?; - let last_arg = args.last()?; - let args_range = TextRange::new( - first_arg.syntax().text_range().start(), - last_arg.syntax().text_range().end(), - ); - let range = document.to_lsp_range(args_range)?; - result.push(ColorInformation { - range, - color: Color { + Some(( + GmodColor { red: components[0], green: components[1], blue: components[2], alpha: components[3], }, - }); - - Some(()) + args, + )) } fn try_build_color_information( @@ -293,35 +330,38 @@ fn try_build_color_information( Some(()) } -fn parse_hex_color(hex: &str) -> Option { - match hex.len() { - 6 => { - // RGB格式 - let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0; - let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0; - let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0; - Some(Color { - red: r, - green: g, - blue: b, - alpha: 1.0, - }) - } - 8 => { - // RGBA格式 - let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0; - let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0; - let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0; - let a = u8::from_str_radix(&hex[6..8], 16).ok()? as f32 / 255.0; - Some(Color { - red: r, - green: g, - blue: b, - alpha: a, - }) - } - _ => None, // 不匹配的长度 +pub(crate) fn gmod_color_from_hex_text(text: &str) -> Option { + gmod_hex_color_from_hex_text(text).map(|hex_color| hex_color.color) +} + +pub(crate) fn gmod_hex_color_from_hex_text(text: &str) -> Option { + let hex = text.strip_prefix('#').unwrap_or(text); + if !matches!(hex.len(), 6 | 8) || !hex.as_bytes().iter().all(u8::is_ascii_hexdigit) { + return None; } + + let red = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0; + let green = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0; + let blue = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0; + let alpha = if hex.len() == 8 { + u8::from_str_radix(&hex[6..8], 16).ok()? as f32 / 255.0 + } else { + 1.0 + }; + + Some(GmodHexColor { + color: GmodColor { + red, + green, + blue, + alpha, + }, + has_alpha: hex.len() == 8, + }) +} + +fn parse_hex_color(hex: &str) -> Option { + gmod_color_from_hex_text(hex).map(GmodColor::to_lsp_color) } pub fn convert_color_to_hex(color: Color, len: usize) -> String { diff --git a/crates/glua_ls/src/handlers/document_color/mod.rs b/crates/glua_ls/src/handlers/document_color/mod.rs index 4529d902..0213ab84 100644 --- a/crates/glua_ls/src/handlers/document_color/mod.rs +++ b/crates/glua_ls/src/handlers/document_color/mod.rs @@ -1,4 +1,4 @@ -mod build_color; +pub(crate) mod build_color; use build_color::{build_colors, convert_color_to_hex}; use glua_code_analysis::SemanticModel; diff --git a/crates/glua_ls/src/handlers/request_handler.rs b/crates/glua_ls/src/handlers/request_handler.rs index 5d5b7839..18d82780 100644 --- a/crates/glua_ls/src/handlers/request_handler.rs +++ b/crates/glua_ls/src/handlers/request_handler.rs @@ -264,6 +264,7 @@ mod tests { uri: Some(uri.clone()), typ: CompletionDataType::Module("foo".to_string()), overload_count: None, + color: None, }) .expect("completion data should serialize"), }); diff --git a/crates/glua_ls/src/handlers/test/completion_test.rs b/crates/glua_ls/src/handlers/test/completion_test.rs index e2958dc2..0933bb24 100644 --- a/crates/glua_ls/src/handlers/test/completion_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_test.rs @@ -3,7 +3,8 @@ mod tests { use glua_code_analysis::{DocSyntax, Emmyrc, EmmyrcFilenameConvention}; use googletest::prelude::*; use lsp_types::{ - CompletionItemKind, CompletionResponse, CompletionTriggerKind, InsertTextFormat, + CompletionItemKind, CompletionResponse, CompletionTriggerKind, Documentation, + InsertTextFormat, MarkupContent, }; use tokio_util::sync::CancellationToken; @@ -1318,7 +1319,7 @@ mod tests { vec![VirtualCompletionItem { label: "nameX".to_string(), kind: CompletionItemKind::CONSTANT, - ..Default::default() + label_detail: Some(" = 1".to_string()), }], )); Ok(()) @@ -1561,7 +1562,7 @@ mod tests { VirtualCompletionItem { label: "GLOBAL".to_string(), kind: CompletionItemKind::CONSTANT, - label_detail: None, + label_detail: Some(" = 0".to_string()), }, VirtualCompletionItem { label: "mod_with_class_and_def".to_string(), @@ -1576,7 +1577,7 @@ mod tests { VirtualCompletionItem { label: "foo".to_string(), kind: CompletionItemKind::CONSTANT, - label_detail: None, + label_detail: Some(" = 0".to_string()), }, VirtualCompletionItem { label: "mod_with_class".to_string(), @@ -1664,7 +1665,7 @@ mod tests { VirtualCompletionItem { label: "x".to_string(), kind: CompletionItemKind::CONSTANT, - label_detail: None, + label_detail: Some(" = 1".to_string()), }, ], )); @@ -1680,7 +1681,7 @@ mod tests { VirtualCompletionItem { label: "foo".to_string(), kind: CompletionItemKind::CONSTANT, - label_detail: None, + label_detail: Some(" = 1".to_string()), }, ], )); @@ -1744,7 +1745,7 @@ mod tests { VirtualCompletionItem { label: "y".to_string(), kind: CompletionItemKind::CONSTANT, - label_detail: None, + label_detail: Some(" = 0".to_string()), }, ], )); @@ -1880,7 +1881,7 @@ mod tests { VirtualCompletionItem { label: "y".to_string(), kind: CompletionItemKind::CONSTANT, - label_detail: None, + label_detail: Some(" = 0".to_string()), }, ], )); @@ -1950,7 +1951,7 @@ mod tests { VirtualCompletionItem { label: "y".to_string(), kind: CompletionItemKind::CONSTANT, - label_detail: None, + label_detail: Some(" = 0".to_string()), }, VirtualCompletionItem { label: "init".to_string(), @@ -2486,12 +2487,12 @@ mod tests { VirtualCompletionItem { label: "\"bar\"".to_string(), kind: CompletionItemKind::CONSTANT, - ..Default::default() + label_detail: Some(" = 2".to_string()), }, VirtualCompletionItem { label: "\"foo\"".to_string(), kind: CompletionItemKind::CONSTANT, - ..Default::default() + label_detail: Some(" = 1".to_string()), }, ], CompletionTriggerKind::TRIGGER_CHARACTER @@ -3654,4 +3655,259 @@ mod tests { Ok(()) } + + #[gtest] + fn test_gmod_color_global_completion_includes_preview_metadata() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@class Color + + ---@return Color + function Color(r, g, b, a) end + + color_white = Color(255, 255, 255, 255) + color_black = Color(0, 0, 0, 255) + + color_w + "#, + )?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .into_iter() + .find(|item| item.label == "color_white") + .ok_or("missing color_white completion") + .or_fail()?; + + verify_eq!(item.kind, Some(CompletionItemKind::COLOR))?; + let label_details = item + .label_details + .as_ref() + .ok_or("missing label details") + .or_fail()?; + verify_that!(label_details.detail.as_ref(), some(eq(" #FFFFFF")))?; + verify_that!(label_details.description.as_ref(), some(eq("Color")))?; + verify_that!( + item.data + .as_ref() + .and_then(|data| data.get("color")) + .and_then(|color| color.get("hex")) + .and_then(|hex| hex.as_str()), + some(eq("#FFFFFF")) + )?; + + let resolved = crate::handlers::completion::completion_resolve( + &ws.analysis, + item, + crate::context::ClientId::VSCode, + ); + let documentation = resolved + .documentation + .as_ref() + .ok_or("missing resolved documentation") + .or_fail()?; + let Documentation::MarkupContent(MarkupContent { value, .. }) = documentation else { + return fail!("expected markdown documentation"); + }; + verify_that!(value, contains_substring("#FFFFFF"))?; + verify_that!(value, contains_substring("rgba(255, 255, 255, 255)"))?; + verify_that!(value, contains_substring("Color(255, 255, 255, 255)"))?; + Ok(()) + } + + #[gtest] + fn test_scalar_constant_completion_includes_literal_detail() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + + check!(ws.check_completion( + r#" + local MAX_PLAYERS = 128 + MAX_PLAY + "#, + vec![VirtualCompletionItem { + label: "MAX_PLAYERS".to_string(), + kind: CompletionItemKind::CONSTANT, + label_detail: Some(" = 128".to_string()), + }], + )); + + check!(ws.check_completion( + r#" + local GAMEMODE_NAME = "sandbox" + GAMEMODE + "#, + vec![VirtualCompletionItem { + label: "GAMEMODE_NAME".to_string(), + kind: CompletionItemKind::CONSTANT, + label_detail: Some(" = \"sandbox\"".to_string()), + }], + )); + + check!(ws.check_completion( + r#" + local IS_CLIENTSIDE = true + IS_CLIENT + "#, + vec![VirtualCompletionItem { + label: "IS_CLIENTSIDE".to_string(), + kind: CompletionItemKind::CONSTANT, + label_detail: Some(" = true".to_string()), + }], + )); + + Ok(()) + } + + #[gtest] + fn test_doc_boolean_constant_completion_is_constant() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + + check!(ws.check_completion( + r#" + ---@type true + local IS_CLIENTSIDE + IS_CLIENT + "#, + vec![VirtualCompletionItem { + label: "IS_CLIENTSIDE".to_string(), + kind: CompletionItemKind::CONSTANT, + label_detail: Some(" = true".to_string()), + }], + )); + + Ok(()) + } + + #[gtest] + fn test_hex_color_string_completion_includes_preview_metadata() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r##" + local ACCENT_COLOR = "#112233FF" + + ACCENT + "##, + )?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .into_iter() + .find(|item| item.label == "ACCENT_COLOR") + .ok_or("missing ACCENT_COLOR completion") + .or_fail()?; + + verify_eq!(item.kind, Some(CompletionItemKind::COLOR))?; + let label_details = item + .label_details + .as_ref() + .ok_or("missing label details") + .or_fail()?; + verify_that!(label_details.detail.as_ref(), some(eq(" #112233FF")))?; + verify_that!(label_details.description.as_ref(), some(eq("Color")))?; + verify_that!( + item.data + .as_ref() + .and_then(|data| data.get("color")) + .and_then(|color| color.get("hex")) + .and_then(|hex| hex.as_str()), + some(eq("#112233FF")) + )?; + verify_that!( + item.data + .as_ref() + .and_then(|data| data.get("color")) + .and_then(|color| color.get("rgb")) + .and_then(|rgb| rgb.as_str()), + some(eq("rgb(17, 34, 51)")) + )?; + Ok(()) + } + + #[gtest] + fn test_gmod_color_member_completion_includes_preview_metadata() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@class Color + + ---@return Color + function Color(r, g, b, a) end + + local SKIN = {} + SKIN.HeaderColor = Color(20, 40, 60, 128) + + SKIN. + "#, + )?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .into_iter() + .find(|item| item.label == "HeaderColor") + .ok_or("missing HeaderColor completion") + .or_fail()?; + + verify_eq!(item.kind, Some(CompletionItemKind::COLOR))?; + let label_details = item + .label_details + .as_ref() + .ok_or("missing label details") + .or_fail()?; + verify_that!(label_details.detail.as_ref(), some(eq(" #14283C80")))?; + verify_that!(label_details.description.as_ref(), some(eq("Color")))?; + verify_that!( + item.data + .as_ref() + .and_then(|data| data.get("color")) + .and_then(|color| color.get("rgba")) + .and_then(|rgba| rgba.as_str()), + some(eq("rgba(20, 40, 60, 128)")) + )?; + verify_that!( + item.data + .as_ref() + .and_then(|data| data.get("color")) + .and_then(|color| color.get("gmod")) + .and_then(|gmod| gmod.as_str()), + some(eq("Color(20, 40, 60, 128)")) + )?; + Ok(()) + } } From 9f34ea30dacdf7c291acb287d5367243e74679f0 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 31 May 2026 08:26:24 +0100 Subject: [PATCH 2/8] feat: add richer completion details --- .../add_completions/add_decl_completion.rs | 46 +- .../add_completions/add_member_completion.rs | 74 +++- .../add_completions/completion_item_info.rs | 74 +++- .../completion/add_completions/mod.rs | 4 +- .../completion/providers/function_provider.rs | 29 +- .../providers/gmod_system_provider.rs | 92 ++-- .../completion/providers/member_provider.rs | 90 +++- .../handlers/completion/resolve_completion.rs | 14 +- .../src/handlers/test/completion_test.rs | 412 ++++++++++++++++-- 9 files changed, 707 insertions(+), 128 deletions(-) diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs index 777575ba..7f41b017 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs @@ -3,15 +3,16 @@ use glua_parser::{LuaAstNode, LuaExpr, LuaSyntaxKind}; use lsp_types::CompletionItem; use crate::handlers::completion::{ - add_completions::get_function_snippet, completion_builder::CompletionBuilder, - completion_data::CompletionData, + add_completions::get_function_snippet, + completion_builder::CompletionBuilder, + completion_data::{CompletionColorInfo, CompletionData}, }; use super::{ CallDisplay, check_visibility, completion_item_info::{ - color_info_from_expr, color_info_from_type, is_color_type, scalar_literal_description, - scalar_literal_detail, + color_info_from_expr, color_info_from_type, gmod_constructor_literal_detail, is_color_type, + is_gmod_literal_constructor_type, scalar_literal_description, scalar_literal_detail, }, get_completion_kind, get_description, get_detail, is_deprecated, }; @@ -26,7 +27,8 @@ pub fn add_decl_completion( check_visibility(builder, property_owner.clone())?; let overload_count = count_function_overloads(builder.semantic_model.get_db(), typ); - let color = get_decl_completion_color(builder, decl_id, typ); + let (color, constructor_literal_detail) = + get_decl_completion_literal_info(builder, decl_id, typ); let literal_detail = scalar_literal_detail(typ); let mut completion_item = CompletionItem { @@ -50,6 +52,7 @@ pub fn add_decl_completion( .as_ref() .map(|color| format!(" {}", color.hex)) .or_else(|| get_detail(builder, typ, CallDisplay::None)) + .or(constructor_literal_detail) .or(literal_detail), description: if color.is_some() { Some("Color".to_string()) @@ -95,21 +98,34 @@ fn count_function_overloads(db: &DbIndex, typ: &LuaType) -> Option { if count == 0 { None } else { Some(count) } } -fn get_decl_completion_color( +fn get_decl_completion_literal_info( builder: &CompletionBuilder, decl_id: LuaDeclId, typ: &LuaType, -) -> Option { - if let Some(color) = color_info_from_type(typ) { - return Some(color); +) -> (Option, Option) { + let mut color = color_info_from_type(typ); + let should_inspect_color = + color.is_none() && (is_color_type(typ) || matches!(typ, LuaType::Unknown)); + let should_inspect_constructor = + is_gmod_literal_constructor_type(typ) || matches!(typ, LuaType::Unknown); + if !should_inspect_color && !should_inspect_constructor { + return (color, None); } - if !is_color_type(typ) && !matches!(typ, LuaType::Unknown) { - return None; + let value_expr = get_decl_value_expr(builder, decl_id); + if should_inspect_color { + color = value_expr.as_ref().and_then(color_info_from_expr); } - let value_expr = get_decl_value_expr(builder, decl_id)?; - color_info_from_expr(&value_expr) + let constructor_literal_detail = if color.is_none() && should_inspect_constructor { + value_expr + .as_ref() + .and_then(gmod_constructor_literal_detail) + } else { + None + }; + + (color, constructor_literal_detail) } fn get_decl_value_expr(builder: &CompletionBuilder, decl_id: LuaDeclId) -> Option { @@ -119,7 +135,7 @@ fn get_decl_value_expr(builder: &CompletionBuilder, decl_id: LuaDeclId) -> Optio .get_decl_index() .get_decl(&decl_id)?; let value_syntax_id = decl.get_value_syntax_id()?; - if !can_expr_syntax_be_color(value_syntax_id.get_kind()) { + if !can_expr_syntax_have_completion_literal(value_syntax_id.get_kind()) { return None; } let tree = builder @@ -131,7 +147,7 @@ fn get_decl_value_expr(builder: &CompletionBuilder, decl_id: LuaDeclId) -> Optio LuaExpr::cast(value_node) } -fn can_expr_syntax_be_color(kind: LuaSyntaxKind) -> bool { +fn can_expr_syntax_have_completion_literal(kind: LuaSyntaxKind) -> bool { matches!( kind, LuaSyntaxKind::CallExpr diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs index c42691ab..edb75300 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs @@ -18,8 +18,8 @@ use crate::handlers::completion::{ use super::{ CallDisplay, check_visibility, completion_item_info::{ - color_info_from_expr, color_info_from_type, is_color_type, scalar_literal_description, - scalar_literal_detail, + color_info_from_expr, color_info_from_type, gmod_constructor_literal_detail, is_color_type, + is_gmod_literal_constructor_type, scalar_literal_description, scalar_literal_detail, }, get_completion_kind, get_description, get_detail, is_deprecated, }; @@ -37,6 +37,16 @@ pub fn add_member_completion( member_info: LuaMemberInfo, status: CompletionTriggerStatus, overload_count: Option, +) -> Option<()> { + add_member_completion_with_description_hint(builder, member_info, status, overload_count, None) +} + +pub fn add_member_completion_with_description_hint( + builder: &mut CompletionBuilder, + member_info: LuaMemberInfo, + status: CompletionTriggerStatus, + overload_count: Option, + description_hint: Option<&str>, ) -> Option<()> { if builder.is_cancelled() { return None; @@ -101,7 +111,8 @@ pub fn add_member_completion( if status == CompletionTriggerStatus::Colon && !remove_nil_type.is_function() { return None; } - let color = get_member_completion_color(builder, property_owner, &remove_nil_type); + let (color, constructor_literal_detail) = + get_member_completion_literal_info(builder, property_owner, &remove_nil_type); // 附加数据, 用于在`resolve`时进一步处理 let completion_data = if let Some(id) = &property_owner { @@ -131,7 +142,9 @@ pub fn add_member_completion( } else if is_inferred_dynamic_member { None } else { - get_detail(builder, &remove_nil_type, call_display).or(literal_detail) + get_detail(builder, &remove_nil_type, call_display) + .or(constructor_literal_detail) + .or(literal_detail) }; // 在`detail`更右侧, 且不紧靠着`detail`显示 let description = if color.is_some() { @@ -142,6 +155,7 @@ pub fn add_member_completion( scalar_literal_description(&remove_nil_type) .or_else(|| get_description(builder, &remove_nil_type)) }; + let description = apply_description_hint(description, description_hint); let deprecated = property_owner .as_ref() @@ -218,29 +232,57 @@ pub fn add_member_completion( deprecated, label, overload_count, + description_hint, ); Some(()) } -fn get_member_completion_color( +fn apply_description_hint( + description: Option, + description_hint: Option<&str>, +) -> Option { + let Some(hint) = description_hint else { + return description; + }; + + Some(match description { + Some(description) => format!("{hint} - {description}"), + None => hint.to_string(), + }) +} + +fn get_member_completion_literal_info( builder: &CompletionBuilder, property_owner: &Option, typ: &LuaType, -) -> Option { - if let Some(color) = color_info_from_type(typ) { - return Some(color); +) -> (Option, Option) { + let mut color = color_info_from_type(typ); + let should_inspect_color = + color.is_none() && (is_color_type(typ) || matches!(typ, LuaType::Unknown)); + let should_inspect_constructor = + is_gmod_literal_constructor_type(typ) || matches!(typ, LuaType::Unknown); + if !should_inspect_color && !should_inspect_constructor { + return (color, None); } - if !is_color_type(typ) && !matches!(typ, LuaType::Unknown) { - return None; + let Some(LuaSemanticDeclId::Member(member_id)) = property_owner.as_ref() else { + return (color, None); + }; + let value_expr = get_member_value_expr(builder.semantic_model.get_db(), *member_id); + if should_inspect_color { + color = value_expr.as_ref().and_then(color_info_from_expr); } - let LuaSemanticDeclId::Member(member_id) = property_owner.as_ref()? else { - return None; + let constructor_literal_detail = if color.is_none() && should_inspect_constructor { + value_expr + .as_ref() + .and_then(gmod_constructor_literal_detail) + } else { + None }; - let value_expr = get_member_value_expr(builder.semantic_model.get_db(), *member_id)?; - color_info_from_expr(&value_expr) + + (color, constructor_literal_detail) } fn get_member_value_expr(db: &DbIndex, member_id: LuaMemberId) -> Option { @@ -279,6 +321,7 @@ fn add_signature_overloads( deprecated: Option, label: String, overload_count: Option, + description_hint: Option<&str>, ) -> Option<()> { let signature_id = match typ { LuaType::Signature(signature_id) => signature_id, @@ -298,7 +341,8 @@ fn add_signature_overloads( .enumerate() .for_each(|(index, overload)| { let typ = LuaType::DocFunction(overload); - let description = get_description(builder, &typ); + let description = + apply_description_hint(get_description(builder, &typ), description_hint); let detail = get_detail(builder, &typ, call_display); let data = if let Some(id) = &property_owner { CompletionData::from_overload(builder, id.clone(), index, overload_count) diff --git a/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs b/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs index 83a8d1a9..efd9e834 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs @@ -1,5 +1,5 @@ use glua_code_analysis::{LuaType, LuaUnionType}; -use glua_parser::{LuaExpr, LuaLiteralToken}; +use glua_parser::{LuaExpr, LuaLiteralToken, UnaryOperator}; use crate::handlers::{ completion::completion_data::CompletionColorInfo, @@ -70,6 +70,56 @@ pub(super) fn scalar_literal_description(typ: &LuaType) -> Option { } } +pub(super) fn gmod_constructor_literal_detail(expr: &LuaExpr) -> Option { + let LuaExpr::CallExpr(call_expr) = expr else { + return None; + }; + + let prefix = call_expr.get_prefix_expr()?; + let LuaExpr::NameExpr(name_expr) = &prefix else { + return None; + }; + let name_token = name_expr.get_name_token()?; + let constructor_name = name_token.get_name_text(); + if !is_gmod_literal_constructor_name(&constructor_name) { + return None; + } + + let args = call_expr.get_args_list()?.get_args().collect::>(); + if args.len() != 3 { + return None; + } + + let components = args + .iter() + .map(numeric_literal_text) + .collect::>>()?; + + Some(format!( + " = {}({})", + constructor_name, + components.join(", ") + )) +} + +pub(super) fn is_gmod_literal_constructor_type(typ: &LuaType) -> bool { + match typ { + LuaType::Ref(id) | LuaType::Def(id) => { + is_gmod_literal_constructor_name(&id.get_simple_name()) + } + LuaType::Instance(instance) => is_gmod_literal_constructor_type(instance.get_base()), + LuaType::Union(union) => match union.as_ref() { + LuaUnionType::Nullable(typ) => is_gmod_literal_constructor_type(typ), + LuaUnionType::Multi(types) => types.iter().any(is_gmod_literal_constructor_type), + }, + LuaType::Intersection(intersection) => intersection + .get_types() + .iter() + .any(is_gmod_literal_constructor_type), + _ => false, + } +} + pub(super) fn is_color_type(typ: &LuaType) -> bool { match typ { LuaType::Ref(id) | LuaType::Def(id) => id.get_simple_name() == "Color", @@ -83,6 +133,28 @@ pub(super) fn is_color_type(typ: &LuaType) -> bool { } } +fn is_gmod_literal_constructor_name(name: &str) -> bool { + matches!(name, "Vector" | "Angle") +} + +fn numeric_literal_text(expr: &LuaExpr) -> Option { + match expr { + LuaExpr::LiteralExpr(literal_expr) => { + let LuaLiteralToken::Number(number) = literal_expr.get_literal()? else { + return None; + }; + Some(number.get_number_value().to_string()) + } + LuaExpr::UnaryExpr(unary_expr) + if unary_expr.get_op_token()?.get_op() == UnaryOperator::OpUnm => + { + let inner = unary_expr.get_expr()?; + Some(format!("-{}", numeric_literal_text(&inner)?)) + } + _ => None, + } +} + fn completion_color_info(color: GmodColor, include_alpha_in_hex: bool) -> CompletionColorInfo { let red = (color.red * 255.0).round() as u8; let green = (color.green * 255.0).round() as u8; diff --git a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs index 388dd3cb..7cddda69 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs @@ -5,7 +5,9 @@ mod completion_item_info; pub use add_decl_completion::add_decl_completion; pub use add_member_completion::get_index_alias_name; -pub use add_member_completion::{CompletionTriggerStatus, add_member_completion}; +pub use add_member_completion::{ + CompletionTriggerStatus, add_member_completion_with_description_hint, +}; pub use check_match_word::check_match_word; use glua_code_analysis::{LuaSemanticDeclId, LuaType, RenderLevel}; use lsp_types::CompletionItemKind; diff --git a/crates/glua_ls/src/handlers/completion/providers/function_provider.rs b/crates/glua_ls/src/handlers/completion/providers/function_provider.rs index d726fff2..1f05e5fe 100644 --- a/crates/glua_ls/src/handlers/completion/providers/function_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/function_provider.rs @@ -166,6 +166,7 @@ fn add_type_ref_completion( let completion_item = CompletionItem { label, kind: Some(lsp_types::CompletionItemKind::ENUM_MEMBER), + label_details: enum_label_description(type_ref_id.get_name()), ..Default::default() }; @@ -195,9 +196,9 @@ fn add_union_member_completion( union_types.sort_by_key(|typ| matches!(typ, LuaType::StrTplRef(_))); for union_sub_typ in union_types { - let name = match union_sub_typ { - LuaType::DocStringConst(s) => to_enum_label(builder, s.as_str()), - LuaType::DocIntegerConst(i) => i.to_string(), + let (name, description) = match union_sub_typ { + LuaType::DocStringConst(s) => (to_enum_label(builder, s.as_str()), "string literal"), + LuaType::DocIntegerConst(i) => (i.to_string(), "integer literal"), _ => { dispatch_type(builder, union_sub_typ.clone(), &infer_guard.fork()); continue; @@ -207,6 +208,7 @@ fn add_union_member_completion( let completion_item = CompletionItem { label: name, kind: Some(lsp_types::CompletionItemKind::ENUM_MEMBER), + label_details: enum_label_description(description), ..Default::default() }; @@ -220,6 +222,7 @@ fn add_string_completion(builder: &mut CompletionBuilder, str: &str) -> Option<( let completion_item = CompletionItem { label: to_enum_label(builder, str), kind: Some(lsp_types::CompletionItemKind::ENUM_MEMBER), + label_details: enum_label_description("string literal"), ..Default::default() }; @@ -549,9 +552,9 @@ fn add_multi_line_union_member_completion( infer_guard: &InferGuardRef, ) -> Option<()> { for (union_sub_typ, description) in union_typ.get_unions() { - let name = match union_sub_typ { - LuaType::DocStringConst(s) => to_enum_label(builder, s), - LuaType::DocIntegerConst(i) => i.to_string(), + let (name, literal_description) = match union_sub_typ { + LuaType::DocStringConst(s) => (to_enum_label(builder, s), "string literal"), + LuaType::DocIntegerConst(i) => (i.to_string(), "integer literal"), _ => { dispatch_type(builder, union_sub_typ.clone(), &infer_guard.fork()); continue; @@ -563,12 +566,7 @@ fn add_multi_line_union_member_completion( .map(|description| Documentation::String(description.clone())); let label_details = - description - .as_ref() - .map(|description| lsp_types::CompletionItemLabelDetails { - detail: None, - description: Some(description.clone()), - }); + enum_label_description(description.as_deref().unwrap_or(literal_description)); let completion_item = CompletionItem { label: name, @@ -595,6 +593,13 @@ fn to_enum_label(builder: &CompletionBuilder, str: &str) -> String { } } +fn enum_label_description(description: &str) -> Option { + Some(lsp_types::CompletionItemLabelDetails { + detail: None, + description: Some(description.to_string()), + }) +} + fn add_lambda_completion(builder: &mut CompletionBuilder, func: &LuaFunctionType) -> Option<()> { let params_str = func .get_params() diff --git a/crates/glua_ls/src/handlers/completion/providers/gmod_system_provider.rs b/crates/glua_ls/src/handlers/completion/providers/gmod_system_provider.rs index b4d8acf7..8bee1079 100644 --- a/crates/glua_ls/src/handlers/completion/providers/gmod_system_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/gmod_system_provider.rs @@ -286,10 +286,15 @@ fn add_net_read_completion_items(builder: &mut CompletionBuilder) -> bool { Some(InsertTextFormat::PLAIN_TEXT), ) }; + let kind = if needs_bits && write_bits.is_none() { + lsp_types::CompletionItemKind::SNIPPET + } else { + lsp_types::CompletionItemKind::FUNCTION + }; let _ = builder.add_completion_item(CompletionItem { label: read_kind.to_fn_name().to_string(), - kind: Some(lsp_types::CompletionItemKind::FUNCTION), + kind: Some(kind), detail: Some(detail), sort_text: Some(format!("000_gmod_net_read_{index:03}")), insert_text: Some(insert_text.clone()), @@ -380,12 +385,10 @@ fn add_net_message_completion_items( }); let _ = builder.add_completion_item(CompletionItem { label: name, - kind: Some(lsp_types::CompletionItemKind::CONSTANT), + kind: Some(lsp_types::CompletionItemKind::EVENT), label_details: Some(lsp_types::CompletionItemLabelDetails { - detail: Some(format!( - "({registration_count} registrations, {receiver_count} receivers)" - )), - description: None, + detail: Some(net_message_label_detail(registration_count, receiver_count)), + description: Some("GMod net message".to_string()), }), detail: Some("GMod net message".to_string()), text_edit, @@ -421,12 +424,10 @@ fn add_staged_net_receive_completion_items( let snippet = build_net_receive_snippet(&name, call_realm); let _ = builder.add_completion_item(CompletionItem { label: name.clone(), - kind: Some(lsp_types::CompletionItemKind::CONSTANT), + kind: Some(lsp_types::CompletionItemKind::EVENT), label_details: Some(lsp_types::CompletionItemLabelDetails { - detail: Some(format!( - "({registration_count} registrations, {receiver_count} receivers)" - )), - description: None, + detail: Some(net_message_label_detail(registration_count, receiver_count)), + description: Some("GMod net message".to_string()), }), detail: Some("GMod net message".to_string()), insert_text_format: Some(InsertTextFormat::SNIPPET), @@ -454,21 +455,12 @@ fn add_hook_completion_items( new_text: name.clone(), }) }); - let args_detail = stats - .callback_params - .as_ref() - .filter(|(_, params)| !params.is_empty()) - .map(|(_, params)| format!(" args: {}", params.join(", "))) - .unwrap_or_default(); let _ = builder.add_completion_item(CompletionItem { label: name, - kind: Some(lsp_types::CompletionItemKind::CONSTANT), + kind: Some(lsp_types::CompletionItemKind::EVENT), label_details: Some(lsp_types::CompletionItemLabelDetails { - detail: Some(format!( - "({} add, {} methods, {} emits){}", - stats.add_count, stats.method_count, stats.emit_count, args_detail - )), - description: None, + detail: Some(hook_label_detail(&stats)), + description: Some("GMod hook".to_string()), }), detail: Some("GMod hook".to_string()), data, @@ -497,20 +489,12 @@ fn add_staged_hook_add_completion_items( .callback_params .as_ref() .map_or(&[][..], |(_, params)| params.as_slice()); - let args_detail = if callback_params.is_empty() { - String::new() - } else { - format!(" args: {}", callback_params.join(", ")) - }; let _ = builder.add_completion_item(CompletionItem { label: name.clone(), - kind: Some(lsp_types::CompletionItemKind::CONSTANT), + kind: Some(lsp_types::CompletionItemKind::EVENT), label_details: Some(lsp_types::CompletionItemLabelDetails { - detail: Some(format!( - "({} add, {} methods, {} emits){}", - stats.add_count, stats.method_count, stats.emit_count, args_detail - )), - description: None, + detail: Some(hook_label_detail(&stats)), + description: Some("GMod hook".to_string()), }), detail: Some("GMod hook".to_string()), data, @@ -527,6 +511,46 @@ fn add_staged_hook_add_completion_items( builder.get_completion_items_mut().len() > before_count } +fn net_message_label_detail(registration_count: usize, receiver_count: usize) -> String { + format!( + "({}, {})", + count_label(registration_count, "registration", "registrations"), + count_label(receiver_count, "receiver", "receivers") + ) +} + +fn hook_label_detail(stats: &HookStats) -> String { + let mut source_parts = Vec::with_capacity(3); + if stats.add_count > 0 { + source_parts.push(count_label(stats.add_count, "hook.Add", "hook.Add")); + } + if stats.method_count > 0 { + source_parts.push(count_label(stats.method_count, "method", "methods")); + } + if stats.emit_count > 0 { + source_parts.push(count_label(stats.emit_count, "emit", "emits")); + } + + let source_detail = if source_parts.is_empty() { + "0 sources".to_string() + } else { + source_parts.join(", ") + }; + + if let Some((_, params)) = &stats.callback_params + && !params.is_empty() + { + return format!("({source_detail}; args: {})", params.join(", ")); + } + + format!("({source_detail})") +} + +fn count_label(count: usize, singular: &str, plural: &str) -> String { + let label = if count == 1 { singular } else { plural }; + format!("{count} {label}") +} + fn collect_net_message_stats(builder: &CompletionBuilder) -> Vec<(String, (usize, usize))> { let infer_index = builder.semantic_model.get_db().get_gmod_infer_index(); let mut net_name_stats: HashMap = HashMap::new(); diff --git a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs index 3e995953..1fe02a1b 100644 --- a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs @@ -10,7 +10,7 @@ use rowan::TextSize; use std::collections::{HashMap, HashSet}; use crate::handlers::completion::{ - add_completions::{CompletionTriggerStatus, add_member_completion}, + add_completions::{CompletionTriggerStatus, add_member_completion_with_description_hint}, completion_builder::CompletionBuilder, }; @@ -62,9 +62,15 @@ pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { .semantic_model .get_member_info_map_at_offset(&prefix_type, builder.position_offset) .unwrap_or_default(); + let gmod_owner_name = gmod_hook_owner_name(&prefix_expr, &prefix_type); extend_gmod_hook_fallback_members(builder, &prefix_expr, &prefix_type, &mut member_info_map); - add_completions_for_members(builder, &member_info_map, completion_status) + add_completions_for_members_with_gmod_owner( + builder, + &member_info_map, + completion_status, + gmod_owner_name.as_deref(), + ) } fn extend_gmod_hook_fallback_members( @@ -77,14 +83,7 @@ fn extend_gmod_hook_fallback_members( return; } - let owner_name = match prefix_type { - LuaType::Ref(owner_type_decl_id) => Some(owner_type_decl_id.get_simple_name().to_string()), - _ => match prefix_expr { - LuaExpr::NameExpr(name_expr) => name_expr.get_name_text(), - _ => None, - }, - }; - + let owner_name = gmod_hook_owner_name(prefix_expr, prefix_type); let Some(owner_name) = owner_name else { return; }; @@ -123,6 +122,16 @@ fn extend_gmod_hook_fallback_members( } } +fn gmod_hook_owner_name(prefix_expr: &LuaExpr, prefix_type: &LuaType) -> Option { + match prefix_type { + LuaType::Ref(owner_type_decl_id) => Some(owner_type_decl_id.get_simple_name().to_string()), + _ => match prefix_expr { + LuaExpr::NameExpr(name_expr) => name_expr.get_name_text(), + _ => None, + }, + } +} + fn gmod_hook_owner_candidates(owner_name: &str) -> &'static [&'static str] { if owner_name.eq_ignore_ascii_case("GM") || owner_name.eq_ignore_ascii_case("GAMEMODE") { &["GM", "GAMEMODE", "SANDBOX"] @@ -139,13 +148,22 @@ pub fn add_completions_for_members( builder: &mut CompletionBuilder, members: &HashMap>, completion_status: CompletionTriggerStatus, +) -> Option<()> { + add_completions_for_members_with_gmod_owner(builder, members, completion_status, None) +} + +fn add_completions_for_members_with_gmod_owner( + builder: &mut CompletionBuilder, + members: &HashMap>, + completion_status: CompletionTriggerStatus, + gmod_owner_name: Option<&str>, ) -> Option<()> { // 排序 let mut sorted_entries: Vec<_> = members.iter().collect(); sorted_entries.sort_unstable_by_key(|(name, _)| *name); for (_, member_infos) in sorted_entries { - add_resolve_member_infos(builder, member_infos, completion_status); + add_resolve_member_infos(builder, member_infos, completion_status, gmod_owner_name); } Some(()) @@ -155,6 +173,7 @@ fn add_resolve_member_infos( builder: &mut CompletionBuilder, member_infos: &Vec, completion_status: CompletionTriggerStatus, + gmod_owner_name: Option<&str>, ) -> Option<()> { if member_infos.len() == 1 { let member_info = &member_infos[0]; @@ -178,11 +197,14 @@ fn add_resolve_member_infos( } _ => None, }; - add_member_completion( + let description_hint = + gmod_fallback_description_hint(builder, gmod_owner_name, member_info); + add_member_completion_with_description_hint( builder, member_info.clone(), completion_status, overload_count, + description_hint.as_deref(), ); return Some(()); } @@ -199,22 +221,28 @@ fn add_resolve_member_infos( match resolve_state { MemberResolveState::All => { - add_member_completion( + let description_hint = + gmod_fallback_description_hint(builder, gmod_owner_name, member_info); + add_member_completion_with_description_hint( builder, member_info.clone(), completion_status, overload_count, + description_hint.as_deref(), ); } MemberResolveState::Meta => { if let Some(feature) = member_info.feature && feature.is_meta_decl() { - add_member_completion( + let description_hint = + gmod_fallback_description_hint(builder, gmod_owner_name, member_info); + add_member_completion_with_description_hint( builder, member_info.clone(), completion_status, overload_count, + description_hint.as_deref(), ); } } @@ -222,11 +250,14 @@ fn add_resolve_member_infos( if let Some(feature) = member_info.feature && feature.is_file_decl() { - add_member_completion( + let description_hint = + gmod_fallback_description_hint(builder, gmod_owner_name, member_info); + add_member_completion_with_description_hint( builder, member_info.clone(), completion_status, overload_count, + description_hint.as_deref(), ); } } @@ -236,6 +267,35 @@ fn add_resolve_member_infos( Some(()) } +fn gmod_fallback_description_hint( + builder: &CompletionBuilder, + gmod_owner_name: Option<&str>, + member_info: &LuaMemberInfo, +) -> Option { + if !builder.semantic_model.get_emmyrc().gmod.enabled { + return None; + } + + let owner_name = gmod_owner_name?; + let owner_candidates = gmod_hook_owner_candidates(owner_name); + if owner_candidates.is_empty() { + return None; + } + + let source_owner = get_owner_type_id(builder.semantic_model.get_db(), member_info)? + .get_simple_name() + .to_string(); + if source_owner.eq_ignore_ascii_case(owner_name) + || !owner_candidates + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(source_owner.as_str())) + { + return None; + } + + Some(format!("from {source_owner}")) +} + /// 过滤成员信息,返回需要的成员列表和重载数量 fn filter_member_infos<'a>( semantic_model: &SemanticModel, diff --git a/crates/glua_ls/src/handlers/completion/resolve_completion.rs b/crates/glua_ls/src/handlers/completion/resolve_completion.rs index 22d48706..7dee1eec 100644 --- a/crates/glua_ls/src/handlers/completion/resolve_completion.rs +++ b/crates/glua_ls/src/handlers/completion/resolve_completion.rs @@ -199,9 +199,19 @@ fn apply_color_completion_documentation( let mut color_documentation = String::new(); color_documentation.push_str("\n\n---\n\n"); color_documentation.push_str("**Color preview**\n\n"); + let color_title = format!( + "{} | {} | {} | {}", + color.hex, color.rgb, color.rgba, color.gmod + ); color_documentation.push_str(&format!( - " {}\n\n", - color.hex, color.hex + concat!( + "", + " {}\n\n" + ), + color_title, color.hex, color.hex )); color_documentation.push_str(&format!( "`{}` \n`{}` \n`{}`", diff --git a/crates/glua_ls/src/handlers/test/completion_test.rs b/crates/glua_ls/src/handlers/test/completion_test.rs index 0933bb24..90409159 100644 --- a/crates/glua_ls/src/handlers/test/completion_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_test.rs @@ -371,6 +371,88 @@ mod tests { Ok(()) } + #[gtest] + fn test_literal_union_completion_includes_literal_kind_description() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new_with_init_std_lib(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@param event "AAA"|"BBB" + local function test(event) + end + + test("") + "#, + )?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .iter() + .find(|item| item.label == "AAA") + .ok_or("missing AAA completion") + .or_fail()?; + verify_that!( + item.label_details + .as_ref() + .and_then(|details| details.description.as_ref()), + some(eq("string literal")) + )?; + + Ok(()) + } + + #[gtest] + fn test_integer_union_completion_includes_literal_kind_description() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new_with_init_std_lib(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@param mode 1|2 + local function set_mode(mode) + end + + set_mode() + "#, + )?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .iter() + .find(|item| item.label == "1") + .ok_or("missing 1 completion") + .or_fail()?; + verify_that!( + item.label_details + .as_ref() + .and_then(|details| details.description.as_ref()), + some(eq("integer literal")) + )?; + + Ok(()) + } + #[gtest] fn test_type_comparison() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -2597,16 +2679,45 @@ mod tests { "#, ); - check!(ws.check_completion( + let (content, position) = ProviderVirtualWorkspace::handle_file_content( r#" net.Start("") "#, - vec![VirtualCompletionItem { - label: "known_message".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some("(1 registrations, 0 receivers)".to_string()), - }], - )); + )?; + let file_id = ws.def(content.as_str()); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .iter() + .find(|item| item.label == "known_message") + .ok_or("missing known_message completion") + .or_fail()?; + + verify_eq!(item.kind, Some(CompletionItemKind::EVENT))?; + let label_details = item + .label_details + .as_ref() + .ok_or("missing label details") + .or_fail()?; + verify_that!( + label_details.detail.as_ref(), + some(eq("(1 registration, 0 receivers)")) + )?; + verify_that!( + label_details.description.as_ref(), + some(eq("GMod net message")) + )?; Ok(()) } @@ -2676,6 +2787,74 @@ mod tests { Ok(()) } + #[gtest] + fn test_gmod_net_read_completion_uses_snippet_kind_for_unknown_bits() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.network.completion.smart_read_suggestions = true; + ws.analysis.update_config(emmyrc.into()); + + ws.def_file( + "addons/test/lua/autorun/server/send.lua", + r#" + local BITS = GetConVar("my_bits"):GetInt() + net.Start("MyMsg") + net.WriteUInt(id, BITS) + net.Broadcast() + "#, + ); + + let (receive_content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + net.Receive("MyMsg", function() + local id = net. + end) + "#, + )?; + let file_id = ws.def_file( + "addons/test/lua/autorun/client/receive.lua", + receive_content.as_str(), + ); + + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .iter() + .find(|item| item.label == "net.ReadUInt") + .ok_or("missing net.ReadUInt completion") + .or_fail()?; + let text_edit = item + .text_edit + .as_ref() + .ok_or("missing net.ReadUInt text edit") + .or_fail()?; + let lsp_types::CompletionTextEdit::Edit(text_edit) = text_edit else { + return fail!("expected text edit for net.ReadUInt completion"); + }; + + verify_eq!(item.kind, Some(CompletionItemKind::SNIPPET))?; + verify_that!(text_edit.new_text.as_str(), eq("net.ReadUInt(${1:bits})"))?; + verify_that!( + item.insert_text.as_deref(), + eq(Some("net.ReadUInt(${1:bits})")) + )?; + verify_that!(item.insert_text_format, eq(Some(InsertTextFormat::SNIPPET)))?; + + Ok(()) + } + #[gtest] fn test_gmod_net_read_completion_disabled_when_config_off() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -2797,38 +2976,87 @@ mod tests { vec![ VirtualCompletionItem { label: "CustomEvent".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some("(0 add, 1 methods, 0 emits) args: x, y".to_string()), + kind: CompletionItemKind::EVENT, + label_detail: Some("(1 method; args: x, y)".to_string()), }, VirtualCompletionItem { label: "OnPluginLoaded".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some( - "(0 add, 1 methods, 0 emits) args: client, character".to_string(), - ), + kind: CompletionItemKind::EVENT, + label_detail: Some("(1 method; args: client, character)".to_string()), }, VirtualCompletionItem { label: "PlayerSpawn".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some("(0 add, 1 methods, 0 emits) args: ply".to_string()), + kind: CompletionItemKind::EVENT, + label_detail: Some("(1 method; args: ply)".to_string()), }, VirtualCompletionItem { label: "PlayerSpawnSENT".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some( - "(0 add, 2 methods, 0 emits) args: ply, class_name".to_string(), - ), + kind: CompletionItemKind::EVENT, + label_detail: Some("(2 methods; args: ply, class_name)".to_string()), }, VirtualCompletionItem { label: "Think".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some("(1 add, 0 methods, 0 emits)".to_string()), + kind: CompletionItemKind::EVENT, + label_detail: Some("(1 hook.Add)".to_string()), }, ], )); Ok(()) } + #[gtest] + fn test_gmod_hook_completion_label_includes_source_description() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.analysis.update_config(emmyrc.into()); + ws.def( + r#" + hook.Add("PlayerSpawn", "id", function(a, b) end) + function GM:PlayerSpawn(ply, transition) end + "#, + ); + + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + hook.Run("") + "#, + )?; + let file_id = ws.def(content.as_str()); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .iter() + .find(|item| item.label == "PlayerSpawn") + .ok_or("missing PlayerSpawn completion") + .or_fail()?; + let label_details = item + .label_details + .as_ref() + .ok_or("missing label details") + .or_fail()?; + + verify_eq!(item.kind, Some(CompletionItemKind::EVENT))?; + verify_that!( + label_details.detail.as_ref(), + some(eq("(1 hook.Add, 1 method; args: ply, transition)")) + )?; + verify_that!(label_details.description.as_ref(), some(eq("GMod hook")))?; + + Ok(()) + } + #[gtest] fn test_gmod_hook_completion_in_hook_add_string_context() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -2847,8 +3075,8 @@ mod tests { "#, vec![VirtualCompletionItem { label: "PlayerSpawn".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some("(0 add, 1 methods, 0 emits) args: ply, transition".to_string()), + kind: CompletionItemKind::EVENT, + label_detail: Some("(1 method; args: ply, transition)".to_string()), }], )); @@ -2875,8 +3103,8 @@ mod tests { "#, vec![VirtualCompletionItem { label: "PlayerSpawn".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some("(1 add, 1 methods, 0 emits) args: ply, transition".to_string()), + kind: CompletionItemKind::EVENT, + label_detail: Some("(1 hook.Add, 1 method; args: ply, transition)".to_string()), }], )); @@ -3057,6 +3285,7 @@ mod tests { return fail!("expected text edit for staged hook.Add completion"); }; + verify_eq!(item.kind, Some(CompletionItemKind::EVENT))?; verify_that!( text_edit.new_text.as_str(), eq("PlayerSpawn\", \"${1:identifier}\", function(ply, transition)\n\t$0\nend)") @@ -3098,10 +3327,13 @@ mod tests { CompletionResponse::List(list) => list.items, }; - verify_that!( - items.iter().any(|item| item.label == "PlayerSpawn"), - eq(true) - )?; + let item = items + .iter() + .find(|item| item.label == "PlayerSpawn") + .ok_or("missing PlayerSpawn completion") + .or_fail()?; + + verify_eq!(item.kind, Some(CompletionItemKind::EVENT))?; Ok(()) } @@ -3207,6 +3439,7 @@ mod tests { return fail!("expected text edit for staged net.Receive completion"); }; + verify_eq!(item.kind, Some(CompletionItemKind::EVENT))?; verify_that!( text_edit.new_text.as_str(), eq("MyMsg\", function(len, ply)\n\t$0\nend)") @@ -3245,8 +3478,8 @@ mod tests { "#, vec![VirtualCompletionItem { label: "ClientOnlyHook".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some("(0 add, 1 methods, 0 emits) args: ply".to_string()), + kind: CompletionItemKind::EVENT, + label_detail: Some("(1 method; args: ply)".to_string()), }], )); Ok(()) @@ -3277,8 +3510,8 @@ mod tests { "#, vec![VirtualCompletionItem { label: "AnnotatedClientHook".to_string(), - kind: CompletionItemKind::CONSTANT, - label_detail: Some("(0 add, 1 methods, 0 emits) args: ply".to_string()), + kind: CompletionItemKind::EVENT, + label_detail: Some("(1 method; args: ply)".to_string()), }], )); Ok(()) @@ -3652,6 +3885,11 @@ mod tests { .as_ref() .and_then(|details| details.detail.clone()); verify_eq!(item_detail, Some("(player, entity)".to_string()))?; + let item_description = item + .label_details + .as_ref() + .and_then(|details| details.description.clone()); + verify_eq!(item_description, Some("from GM".to_string()))?; Ok(()) } @@ -3723,6 +3961,14 @@ mod tests { return fail!("expected markdown documentation"); }; verify_that!(value, contains_substring("#FFFFFF"))?; + verify_that!(value, contains_substring("display:inline-block"))?; + verify_that!(value, contains_substring("background-color:#FFFFFF"))?; + verify_that!( + value, + contains_substring( + "title=\"#FFFFFF | rgb(255, 255, 255) | rgba(255, 255, 255, 255) | Color(255, 255, 255, 255)\"" + ) + )?; verify_that!(value, contains_substring("rgba(255, 255, 255, 255)"))?; verify_that!(value, contains_substring("Color(255, 255, 255, 255)"))?; Ok(()) @@ -3910,4 +4156,104 @@ mod tests { )?; Ok(()) } + + #[gtest] + fn test_gmod_constructor_completion_includes_literal_detail() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + + check!(ws.check_completion( + r#" + ---@class Vector + ---@return Vector + function Vector(x, y, z) end + + local SPAWN_POS = Vector(-200, 0, 50) + SPAWN + "#, + vec![VirtualCompletionItem { + label: "SPAWN_POS".to_string(), + kind: CompletionItemKind::VARIABLE, + label_detail: Some(" = Vector(-200, 0, 50)".to_string()), + }], + )); + + check!(ws.check_completion( + r#" + ---@class Angle + ---@return Angle + function Angle(p, y, r) end + + local CAMERA_ANGLE = Angle(10.5, 180, 0) + CAMERA + "#, + vec![VirtualCompletionItem { + label: "CAMERA_ANGLE".to_string(), + kind: CompletionItemKind::VARIABLE, + label_detail: Some(" = Angle(10.5, 180, 0)".to_string()), + }], + )); + + Ok(()) + } + + #[gtest] + fn test_gmod_constructor_member_completion_includes_literal_detail() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + + check!(ws.check_completion( + r#" + ---@class Vector + ---@return Vector + function Vector(x, y, z) end + + ---@class Angle + ---@return Angle + function Angle(p, y, r) end + + local ENT = {} + ENT.CameraOffset = Vector(-200, 0, 50) + ENT.SpawnAngle = Angle(0, 180, 0) + + ENT. + "#, + vec![ + VirtualCompletionItem { + label: "CameraOffset".to_string(), + kind: CompletionItemKind::VARIABLE, + label_detail: Some(" = Vector(-200, 0, 50)".to_string()), + }, + VirtualCompletionItem { + label: "SpawnAngle".to_string(), + kind: CompletionItemKind::VARIABLE, + label_detail: Some(" = Angle(0, 180, 0)".to_string()), + }, + ], + )); + + Ok(()) + } + + #[gtest] + fn test_dynamic_gmod_constructor_completion_does_not_include_literal_detail() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + + check!(ws.check_completion( + r#" + ---@class Vector + ---@return Vector + function Vector(x, y, z) end + + local x = 1 + local DYNAMIC_POS = Vector(x, 0, 50) + DYNAMIC + "#, + vec![VirtualCompletionItem { + label: "DYNAMIC_POS".to_string(), + kind: CompletionItemKind::VARIABLE, + label_detail: None, + }], + )); + + Ok(()) + } } From 14e247f7fd5eeabc087e85d0fe36f072f168150e Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 31 May 2026 09:09:29 +0100 Subject: [PATCH 3/8] perf: cache fallback completion owners --- .../completion/providers/member_provider.rs | 92 ++++++++++--------- 1 file changed, 51 insertions(+), 41 deletions(-) diff --git a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs index 1fe02a1b..c8b3cedc 100644 --- a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs @@ -63,36 +63,32 @@ pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { .get_member_info_map_at_offset(&prefix_type, builder.position_offset) .unwrap_or_default(); let gmod_owner_name = gmod_hook_owner_name(&prefix_expr, &prefix_type); - extend_gmod_hook_fallback_members(builder, &prefix_expr, &prefix_type, &mut member_info_map); + let gmod_fallback_owner = if builder.semantic_model.get_emmyrc().gmod.enabled { + gmod_owner_name + .as_deref() + .and_then(gmod_hook_fallback_owner) + } else { + None + }; + extend_gmod_hook_fallback_members(builder, gmod_fallback_owner, &mut member_info_map); add_completions_for_members_with_gmod_owner( builder, &member_info_map, completion_status, - gmod_owner_name.as_deref(), + gmod_fallback_owner, ) } fn extend_gmod_hook_fallback_members( builder: &CompletionBuilder, - prefix_expr: &LuaExpr, - prefix_type: &LuaType, + fallback_owner: Option>, members: &mut HashMap>, ) { - if !builder.semantic_model.get_emmyrc().gmod.enabled { - return; - } - - let owner_name = gmod_hook_owner_name(prefix_expr, prefix_type); - let Some(owner_name) = owner_name else { + let Some(fallback_owner) = fallback_owner else { return; }; - let owner_candidates = gmod_hook_owner_candidates(owner_name.as_str()); - if owner_candidates.is_empty() { - return; - } - let mut existing: HashMap>> = HashMap::new(); for (key, infos) in members.iter() { let entry = existing.entry(key.clone()).or_default(); @@ -101,7 +97,7 @@ fn extend_gmod_hook_fallback_members( } } - for owner_candidate in owner_candidates { + for owner_candidate in fallback_owner.candidates { let owner_type = LuaType::Ref(LuaTypeDeclId::global(owner_candidate)); let Some(fallback_map) = builder .semantic_model @@ -132,6 +128,24 @@ fn gmod_hook_owner_name(prefix_expr: &LuaExpr, prefix_type: &LuaType) -> Option< } } +#[derive(Clone, Copy)] +struct GmodFallbackOwner<'a> { + owner_name: &'a str, + candidates: &'static [&'static str], +} + +fn gmod_hook_fallback_owner(owner_name: &str) -> Option> { + let candidates = gmod_hook_owner_candidates(owner_name); + if candidates.is_empty() { + None + } else { + Some(GmodFallbackOwner { + owner_name, + candidates, + }) + } +} + fn gmod_hook_owner_candidates(owner_name: &str) -> &'static [&'static str] { if owner_name.eq_ignore_ascii_case("GM") || owner_name.eq_ignore_ascii_case("GAMEMODE") { &["GM", "GAMEMODE", "SANDBOX"] @@ -156,14 +170,19 @@ fn add_completions_for_members_with_gmod_owner( builder: &mut CompletionBuilder, members: &HashMap>, completion_status: CompletionTriggerStatus, - gmod_owner_name: Option<&str>, + gmod_fallback_owner: Option>, ) -> Option<()> { // 排序 let mut sorted_entries: Vec<_> = members.iter().collect(); sorted_entries.sort_unstable_by_key(|(name, _)| *name); for (_, member_infos) in sorted_entries { - add_resolve_member_infos(builder, member_infos, completion_status, gmod_owner_name); + add_resolve_member_infos( + builder, + member_infos, + completion_status, + gmod_fallback_owner, + ); } Some(()) @@ -173,7 +192,7 @@ fn add_resolve_member_infos( builder: &mut CompletionBuilder, member_infos: &Vec, completion_status: CompletionTriggerStatus, - gmod_owner_name: Option<&str>, + gmod_fallback_owner: Option>, ) -> Option<()> { if member_infos.len() == 1 { let member_info = &member_infos[0]; @@ -198,7 +217,7 @@ fn add_resolve_member_infos( _ => None, }; let description_hint = - gmod_fallback_description_hint(builder, gmod_owner_name, member_info); + gmod_fallback_description_hint(builder, gmod_fallback_owner, member_info); add_member_completion_with_description_hint( builder, member_info.clone(), @@ -222,7 +241,7 @@ fn add_resolve_member_infos( match resolve_state { MemberResolveState::All => { let description_hint = - gmod_fallback_description_hint(builder, gmod_owner_name, member_info); + gmod_fallback_description_hint(builder, gmod_fallback_owner, member_info); add_member_completion_with_description_hint( builder, member_info.clone(), @@ -236,7 +255,7 @@ fn add_resolve_member_infos( && feature.is_meta_decl() { let description_hint = - gmod_fallback_description_hint(builder, gmod_owner_name, member_info); + gmod_fallback_description_hint(builder, gmod_fallback_owner, member_info); add_member_completion_with_description_hint( builder, member_info.clone(), @@ -251,7 +270,7 @@ fn add_resolve_member_infos( && feature.is_file_decl() { let description_hint = - gmod_fallback_description_hint(builder, gmod_owner_name, member_info); + gmod_fallback_description_hint(builder, gmod_fallback_owner, member_info); add_member_completion_with_description_hint( builder, member_info.clone(), @@ -269,31 +288,22 @@ fn add_resolve_member_infos( fn gmod_fallback_description_hint( builder: &CompletionBuilder, - gmod_owner_name: Option<&str>, + fallback_owner: Option>, member_info: &LuaMemberInfo, ) -> Option { - if !builder.semantic_model.get_emmyrc().gmod.enabled { - return None; - } - - let owner_name = gmod_owner_name?; - let owner_candidates = gmod_hook_owner_candidates(owner_name); - if owner_candidates.is_empty() { - return None; - } - - let source_owner = get_owner_type_id(builder.semantic_model.get_db(), member_info)? - .get_simple_name() - .to_string(); - if source_owner.eq_ignore_ascii_case(owner_name) - || !owner_candidates + let fallback_owner = fallback_owner?; + let source_owner = get_owner_type_id(builder.semantic_model.get_db(), member_info)?; + let source_owner_name = source_owner.get_simple_name(); + if source_owner_name.eq_ignore_ascii_case(fallback_owner.owner_name) + || !fallback_owner + .candidates .iter() - .any(|candidate| candidate.eq_ignore_ascii_case(source_owner.as_str())) + .any(|candidate| candidate.eq_ignore_ascii_case(source_owner_name)) { return None; } - Some(format!("from {source_owner}")) + Some(format!("from {source_owner_name}")) } /// 过滤成员信息,返回需要的成员列表和重载数量 From 9f7897b36054ebc48601ca68bbe9b35ef03b9914 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 31 May 2026 14:15:13 +0100 Subject: [PATCH 4/8] perf: keep doc boolean completions out of analysis --- crates/glua_code_analysis/src/db_index/type/types.rs | 1 - .../glua_ls/src/handlers/completion/add_completions/mod.rs | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/type/types.rs b/crates/glua_code_analysis/src/db_index/type/types.rs index bc434a6e..b4dd04d4 100644 --- a/crates/glua_code_analysis/src/db_index/type/types.rs +++ b/crates/glua_code_analysis/src/db_index/type/types.rs @@ -420,7 +420,6 @@ impl LuaType { | LuaType::TableConst(_) | LuaType::DocStringConst(_) | LuaType::DocIntegerConst(_) - | LuaType::DocBooleanConst(_) ) } diff --git a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs index 7cddda69..2dfd03c9 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs @@ -36,7 +36,7 @@ pub fn check_visibility(builder: &mut CompletionBuilder, id: LuaSemanticDeclId) pub fn get_completion_kind(typ: &LuaType) -> CompletionItemKind { if typ.is_function() { return CompletionItemKind::FUNCTION; - } else if typ.is_const() { + } else if is_completion_constant_type(typ) { return CompletionItemKind::CONSTANT; } else if typ.is_def() { return CompletionItemKind::CLASS; @@ -47,6 +47,10 @@ pub fn get_completion_kind(typ: &LuaType) -> CompletionItemKind { CompletionItemKind::VARIABLE } +fn is_completion_constant_type(typ: &LuaType) -> bool { + typ.is_const() || matches!(typ, LuaType::DocBooleanConst(_)) +} + pub fn is_deprecated(builder: &CompletionBuilder, id: LuaSemanticDeclId) -> bool { let property = builder .semantic_model From ff11c4b2ead488249b4d2f6b31a7cce13a2f3d11 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 31 May 2026 17:54:56 +0100 Subject: [PATCH 5/8] feat: various completion QoL changes --- .../code_actions/actions/build_fix_code.rs | 111 +++++++- .../handlers/code_actions/build_actions.rs | 5 +- .../add_completions/add_member_completion.rs | 5 + .../providers/auto_require_provider.rs | 11 + .../providers/file_path_provider.rs | 105 +++++++- .../providers/gmod_system_provider.rs | 110 +++++++- .../src/handlers/test/code_actions_test.rs | 170 +++++++++++- .../src/handlers/test/completion_test.rs | 250 +++++++++++++++++- 8 files changed, 746 insertions(+), 21 deletions(-) diff --git a/crates/glua_ls/src/handlers/code_actions/actions/build_fix_code.rs b/crates/glua_ls/src/handlers/code_actions/actions/build_fix_code.rs index 44c670f4..91baccd5 100644 --- a/crates/glua_ls/src/handlers/code_actions/actions/build_fix_code.rs +++ b/crates/glua_ls/src/handlers/code_actions/actions/build_fix_code.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use crate::handlers::command::make_auto_doc_tag_command; -use glua_code_analysis::SemanticModel; -use glua_parser::{LuaAstNode, LuaExpr}; +use glua_code_analysis::{LuaDocument, SemanticModel}; +use glua_parser::{BinaryOperator, LuaAstNode, LuaBinaryExpr, LuaExpr}; use lsp_types::{CodeAction, CodeActionKind, CodeActionOrCommand, Range, TextEdit, WorkspaceEdit}; use rowan::{NodeOrToken, TokenAtOffset}; @@ -76,6 +76,113 @@ pub fn build_add_doc_tag( Some(()) } +pub fn build_gmod_null_check( + semantic_model: &SemanticModel, + actions: &mut Vec, + range: Range, + _data: &Option, +) -> Option<()> { + let document = semantic_model.get_document(); + let target_range = document.to_rowan_range(range)?; + let root = semantic_model.get_root(); + let token = match root.syntax().token_at_offset(target_range.start()) { + TokenAtOffset::Single(token) => token, + TokenAtOffset::Between(_, token) => token, + _ => return None, + }; + + let mut current_node = token.parent(); + while let Some(node) = current_node { + if node.text_range() == target_range { + if let Some(binary_expr) = LuaBinaryExpr::cast(node.clone()) { + if let Some(replacement) = + build_gmod_null_binary_replacement(semantic_model, &document, &binary_expr) + { + push_gmod_null_check_action(actions, &document, range, replacement); + } + return Some(()); + } + + if LuaExpr::can_cast(node.kind().into()) { + let expr_text = document.get_text_slice(target_range).trim(); + if !expr_text.is_empty() { + push_gmod_null_check_action( + actions, + &document, + range, + format!("IsValid({expr_text})"), + ); + } + return Some(()); + } + } + + current_node = node.parent(); + } + + Some(()) +} + +fn build_gmod_null_binary_replacement( + semantic_model: &SemanticModel, + document: &LuaDocument, + binary_expr: &LuaBinaryExpr, +) -> Option { + let op = binary_expr.get_op_token()?.get_op(); + if !matches!(op, BinaryOperator::OpEq | BinaryOperator::OpNe) { + return None; + } + + let (left, right) = binary_expr.get_exprs()?; + let checked_expr = match ( + is_nil_expr(semantic_model, &left), + is_nil_expr(semantic_model, &right), + ) { + (false, true) => left, + (true, false) => right, + _ => return None, + }; + + let expr_text = document + .get_text_slice(checked_expr.syntax().text_range()) + .trim(); + if expr_text.is_empty() { + return None; + } + + let is_valid_call = format!("IsValid({expr_text})"); + match op { + BinaryOperator::OpEq => Some(format!("not {is_valid_call}")), + BinaryOperator::OpNe => Some(is_valid_call), + _ => None, + } +} + +fn is_nil_expr(semantic_model: &SemanticModel, expr: &LuaExpr) -> bool { + semantic_model + .infer_expr(expr.clone()) + .is_ok_and(|expr_type| expr_type.is_nil()) +} + +fn push_gmod_null_check_action( + actions: &mut Vec, + document: &LuaDocument, + range: Range, + new_text: String, +) { + let text_edit = TextEdit { range, new_text }; + + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: t!("Use IsValid(...) for GMod NULL check").to_string(), + kind: Some(CodeActionKind::QUICKFIX), + edit: Some(WorkspaceEdit { + changes: Some(HashMap::from([(document.get_uri(), vec![text_edit])])), + ..Default::default() + }), + ..Default::default() + })); +} + pub fn build_preferred_local_alias_fix( semantic_model: &SemanticModel, actions: &mut Vec, diff --git a/crates/glua_ls/src/handlers/code_actions/build_actions.rs b/crates/glua_ls/src/handlers/code_actions/build_actions.rs index 41f8c95d..a9b90add 100644 --- a/crates/glua_ls/src/handlers/code_actions/build_actions.rs +++ b/crates/glua_ls/src/handlers/code_actions/build_actions.rs @@ -6,7 +6,7 @@ use lsp_types::{ use super::actions::{ build_add_doc_tag, build_disable_file_changes, build_disable_next_line_changes, - build_need_check_nil, build_preferred_local_alias_fix, + build_gmod_null_check, build_need_check_nil, build_preferred_local_alias_fix, }; use crate::handlers::command::{DisableAction, make_disable_code_command}; @@ -72,6 +72,9 @@ fn add_fix_code_action( DiagnosticCode::PreferredLocalAlias => { build_preferred_local_alias_fix(semantic_model, actions, range, data) } + DiagnosticCode::GmodNullCheck => { + build_gmod_null_check(semantic_model, actions, range, data) + } _ => Some(()), } } diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs index edb75300..4f973ed3 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs @@ -495,6 +495,11 @@ fn try_add_alias_completion_item_new( let mut alias_completion_item = completion_item.clone(); alias_completion_item.label = alias_label; alias_completion_item.insert_text = Some(label.clone()); + alias_completion_item.filter_text = Some(format!( + "{} {}", + alias_completion_item.label.as_str(), + label.as_str() + )); // 更新 label_details 添加别名提示 let index_hint = t!("completion.index %{label}", label = label).to_string(); diff --git a/crates/glua_ls/src/handlers/completion/providers/auto_require_provider.rs b/crates/glua_ls/src/handlers/completion/providers/auto_require_provider.rs index 72f47925..cdf9a72e 100644 --- a/crates/glua_ls/src/handlers/completion/providers/auto_require_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/auto_require_provider.rs @@ -101,6 +101,10 @@ fn add_module_completion_item( let completion_item = CompletionItem { label: completion_name.clone(), kind: Some(lsp_types::CompletionItemKind::MODULE), + filter_text: Some(format!( + "{} {}", + completion_name, module_info.full_module_name + )), label_details: Some(lsp_types::CompletionItemLabelDetails { detail: Some(format!(" (in {})", module_info.full_module_name)), ..Default::default() @@ -196,6 +200,12 @@ fn add_completion_item_by_type( let completion_item = CompletionItem { label: key_name.clone(), kind: Some(get_completion_kind(&member_info.typ)), + filter_text: Some(format!( + "{} {} {}", + key_name, + module_info.full_module_name, + member_info.key.to_path() + )), label_details: Some(lsp_types::CompletionItemLabelDetails { detail: Some(format!(" (in {})", module_info.full_module_name)), ..Default::default() @@ -233,6 +243,7 @@ fn add_completion_item_by_type( let completion_item = CompletionItem { label: name.to_string(), kind: Some(get_completion_kind(&export_type)), + filter_text: Some(format!("{} {}", name, module_info.full_module_name)), label_details: Some(lsp_types::CompletionItemLabelDetails { detail: Some(format!(" (in {})", module_info.full_module_name)), ..Default::default() diff --git a/crates/glua_ls/src/handlers/completion/providers/file_path_provider.rs b/crates/glua_ls/src/handlers/completion/providers/file_path_provider.rs index a5a728e1..980d044e 100644 --- a/crates/glua_ls/src/handlers/completion/providers/file_path_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/file_path_provider.rs @@ -20,6 +20,12 @@ enum PathCompletionKind { Any, } +struct PathCompletionContext { + roots: Vec, + kind: PathCompletionKind, + lua_loader: bool, +} + pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { if builder.is_cancelled() { return None; @@ -38,12 +44,13 @@ pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { let roots = context .as_ref() - .map(|(roots, _)| roots.clone()) + .map(|context| context.roots.clone()) .unwrap_or_else(|| collect_resource_roots(builder)); let completion_kind = context .as_ref() - .map(|(_, completion_kind)| *completion_kind) + .map(|context| context.kind) .unwrap_or(PathCompletionKind::Any); + let lua_loader = context.as_ref().is_some_and(|context| context.lua_loader); let mut seen_insert_text = HashSet::new(); let mut added_any = false; @@ -71,7 +78,7 @@ pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { continue; } - if completion_kind == PathCompletionKind::Folder && !path.is_dir() { + if !should_include_path_completion(&path, path.is_dir(), completion_kind, lua_loader) { continue; } @@ -81,6 +88,7 @@ pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { name, &prefix, text_edit_range, + lua_loader, &mut seen_insert_text, ) .is_some() @@ -100,7 +108,7 @@ pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { fn detect_path_context( builder: &CompletionBuilder, string_token: &LuaStringToken, -) -> Option<(Vec, PathCompletionKind)> { +) -> Option { let literal_expr = string_token.get_parent::()?; let args_list = literal_expr.get_parent::()?; let call_expr = args_list.get_parent::()?; @@ -108,12 +116,21 @@ fn detect_path_context( .get_args() .position(|arg| arg.get_position() == literal_expr.get_position())?; - if is_include_loader_context(&call_expr, arg_idx) { - return Some((collect_contextual_roots(builder), PathCompletionKind::File)); + let explicit_completion_kind = infer_param_path_completion_kind(builder, &call_expr, arg_idx); + if explicit_completion_kind.is_none() && is_include_loader_context(&call_expr, arg_idx) { + return Some(PathCompletionContext { + roots: collect_contextual_roots(builder), + kind: PathCompletionKind::File, + lua_loader: true, + }); } - let completion_kind = infer_param_path_completion_kind(builder, &call_expr, arg_idx)?; - Some((collect_contextual_roots(builder), completion_kind)) + let completion_kind = explicit_completion_kind?; + Some(PathCompletionContext { + roots: collect_contextual_roots(builder), + kind: completion_kind, + lua_loader: false, + }) } fn is_include_loader_context(call_expr: &LuaCallExpr, arg_idx: usize) -> bool { @@ -125,11 +142,7 @@ fn is_include_loader_context(call_expr: &LuaCallExpr, arg_idx: usize) -> bool { return false; }; - matches_call_path(&call_path, "include") || matches_call_path(&call_path, "AddCSLuaFile") -} - -fn matches_call_path(path: &str, target: &str) -> bool { - path == target || path.ends_with(&format!(".{target}")) || path.ends_with(&format!(":{target}")) + matches!(call_path.as_str(), "include" | "AddCSLuaFile") } fn infer_param_path_completion_kind( @@ -310,12 +323,36 @@ fn split_path_prefix(path: &str) -> (String, String) { } } +fn should_include_path_completion( + path: &Path, + is_dir: bool, + completion_kind: PathCompletionKind, + lua_loader: bool, +) -> bool { + if is_dir { + return true; + } + + if completion_kind == PathCompletionKind::Folder { + return false; + } + + !lua_loader || is_lua_file(path) +} + +fn is_lua_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("lua")) +} + fn add_file_path_completion( builder: &mut CompletionBuilder, path: &PathBuf, name: &str, prefix: &str, text_edit_range: lsp_types::Range, + lua_loader: bool, seen_insert_text: &mut HashSet, ) -> Option<()> { let kind: lsp_types::CompletionItemKind = if path.is_dir() { @@ -339,6 +376,17 @@ fn add_file_path_completion( label: name.to_string(), kind: Some(kind), filter_text: Some(filter_text), + sort_text: lua_loader.then(|| { + format!( + "020_gmod_lua_path_{}_{}", + if path.is_dir() { 0 } else { 1 }, + name.to_ascii_lowercase() + ) + }), + label_details: lua_loader.then(|| lsp_types::CompletionItemLabelDetails { + description: Some("GMod Lua path".to_string()), + ..Default::default() + }), text_edit: Some(lsp_types::CompletionTextEdit::Edit(text_edit)), detail, ..Default::default() @@ -353,8 +401,9 @@ fn add_file_path_completion( mod tests { use super::{ PathCompletionKind, classify_path_type_name, map_call_param_to_decl_param_idx, - merge_path_completion_kind, + merge_path_completion_kind, should_include_path_completion, }; + use std::path::Path; #[test] fn test_classify_path_type_name_file_folder_and_path() { @@ -392,4 +441,32 @@ mod tests { PathCompletionKind::Any ); } + + #[test] + fn test_gmod_lua_loader_path_filter_allows_dirs_and_lua_files_only() { + assert!(should_include_path_completion( + Path::new("entities"), + true, + PathCompletionKind::File, + true + )); + assert!(should_include_path_completion( + Path::new("cl_init.lua"), + false, + PathCompletionKind::File, + true + )); + assert!(!should_include_path_completion( + Path::new("icon.png"), + false, + PathCompletionKind::File, + true + )); + assert!(should_include_path_completion( + Path::new("icon.png"), + false, + PathCompletionKind::File, + false + )); + } } diff --git a/crates/glua_ls/src/handlers/completion/providers/gmod_system_provider.rs b/crates/glua_ls/src/handlers/completion/providers/gmod_system_provider.rs index 8bee1079..0bcff445 100644 --- a/crates/glua_ls/src/handlers/completion/providers/gmod_system_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/gmod_system_provider.rs @@ -68,6 +68,14 @@ pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { } else if is_hook_name_string_context(builder, &call_expr, literal_expr) { if staged_call_snippets_enabled(builder) && matches_call_path(&call_path, "hook.Add") { add_staged_hook_add_completion_items(builder, &call_expr) + } else if staged_call_snippets_enabled(builder) + && matches_call_path(&call_path, "hook.Run") + { + add_staged_hook_emit_completion_items(builder, &call_expr, false) + } else if staged_call_snippets_enabled(builder) + && matches_call_path(&call_path, "hook.Call") + { + add_staged_hook_emit_completion_items(builder, &call_expr, true) } else { add_hook_completion_items(builder, Some(text_edit_range)) } @@ -100,12 +108,21 @@ pub fn apply_staged_call_snippet( .find_map(LuaIndexExpr::cast)?; let prefix_path = expr_access_path(&index_expr.get_prefix_expr()?)?; let call_path = format!("{prefix_path}.{label}"); - if !matches_call_path(&call_path, "hook.Add") && !matches_call_path(&call_path, "net.Receive") { + if !matches_call_path(&call_path, "hook.Add") + && !matches_call_path(&call_path, "hook.Run") + && !matches_call_path(&call_path, "hook.Call") + && !matches_call_path(&call_path, "net.Receive") + { return None; } - completion_item.insert_text = Some(format!(r#"{}("${{1}}")"#, label)); + completion_item.insert_text = Some(if matches_call_path(&call_path, "hook.Call") { + format!(r#"{}("${{1}}", ${{2:GAMEMODE}})"#, label) + } else { + format!(r#"{}("${{1}}")"#, label) + }); completion_item.insert_text_format = Some(InsertTextFormat::SNIPPET); + completion_item.sort_text = Some(format!("000_gmod_staged_call_{}", label.to_lowercase())); completion_item.command = Some(trigger_suggest_command()); Some(()) } @@ -377,6 +394,8 @@ fn add_net_message_completion_items( ) -> bool { let before_count = builder.get_completion_items_mut().len(); for (name, (registration_count, receiver_count)) in collect_net_message_stats(builder) { + let filter_text = name.clone(); + let sort_text = format!("010_gmod_net_message_{}", completion_sort_key(&name)); let text_edit = text_edit_range.map(|range| { CompletionTextEdit::Edit(TextEdit { range, @@ -391,6 +410,8 @@ fn add_net_message_completion_items( description: Some("GMod net message".to_string()), }), detail: Some("GMod net message".to_string()), + filter_text: Some(filter_text), + sort_text: Some(sort_text), text_edit, ..Default::default() }); @@ -422,6 +443,7 @@ fn add_staged_net_receive_completion_items( for (name, (registration_count, receiver_count)) in collect_net_message_stats(builder) { let snippet = build_net_receive_snippet(&name, call_realm); + let sort_text = format!("000_gmod_net_receive_{}", completion_sort_key(&name)); let _ = builder.add_completion_item(CompletionItem { label: name.clone(), kind: Some(lsp_types::CompletionItemKind::EVENT), @@ -430,6 +452,8 @@ fn add_staged_net_receive_completion_items( description: Some("GMod net message".to_string()), }), detail: Some("GMod net message".to_string()), + filter_text: Some(name.clone()), + sort_text: Some(sort_text), insert_text_format: Some(InsertTextFormat::SNIPPET), insert_text_mode: Some(InsertTextMode::ADJUST_INDENTATION), text_edit: Some(CompletionTextEdit::Edit(TextEdit { @@ -449,6 +473,8 @@ fn add_hook_completion_items( ) -> bool { let before_count = builder.get_completion_items_mut().len(); for (name, stats, data) in collect_hook_completion_entries(builder) { + let filter_text = name.clone(); + let sort_text = format!("010_gmod_hook_name_{}", completion_sort_key(&name)); let text_edit = text_edit_range.map(|range| { CompletionTextEdit::Edit(TextEdit { range, @@ -464,6 +490,8 @@ fn add_hook_completion_items( }), detail: Some("GMod hook".to_string()), data, + filter_text: Some(filter_text), + sort_text: Some(sort_text), text_edit, ..Default::default() }); @@ -489,6 +517,7 @@ fn add_staged_hook_add_completion_items( .callback_params .as_ref() .map_or(&[][..], |(_, params)| params.as_slice()); + let sort_text = format!("000_gmod_hook_add_{}", completion_sort_key(&name)); let _ = builder.add_completion_item(CompletionItem { label: name.clone(), kind: Some(lsp_types::CompletionItemKind::EVENT), @@ -498,6 +527,8 @@ fn add_staged_hook_add_completion_items( }), detail: Some("GMod hook".to_string()), data, + filter_text: Some(name.clone()), + sort_text: Some(sort_text), insert_text_format: Some(InsertTextFormat::SNIPPET), insert_text_mode: Some(InsertTextMode::ADJUST_INDENTATION), text_edit: Some(CompletionTextEdit::Edit(TextEdit { @@ -511,6 +542,49 @@ fn add_staged_hook_add_completion_items( builder.get_completion_items_mut().len() > before_count } +fn add_staged_hook_emit_completion_items( + builder: &mut CompletionBuilder, + call_expr: &LuaCallExpr, + include_gamemode_arg: bool, +) -> bool { + let before_count = builder.get_completion_items_mut().len(); + let Some(string_token) = completion_string_token(builder) else { + return false; + }; + let Some(replace_range) = staged_call_edit_range(builder, &string_token, call_expr) else { + return false; + }; + + for (name, stats, data) in collect_hook_completion_entries(builder) { + let callback_params = stats + .callback_params + .as_ref() + .map_or(&[][..], |(_, params)| params.as_slice()); + let sort_text = format!("000_gmod_hook_emit_{}", completion_sort_key(&name)); + let _ = builder.add_completion_item(CompletionItem { + label: name.clone(), + kind: Some(lsp_types::CompletionItemKind::EVENT), + label_details: Some(lsp_types::CompletionItemLabelDetails { + detail: Some(hook_label_detail(&stats)), + description: Some("GMod hook".to_string()), + }), + detail: Some("GMod hook".to_string()), + data, + filter_text: Some(name.clone()), + sort_text: Some(sort_text), + insert_text_format: Some(InsertTextFormat::SNIPPET), + insert_text_mode: Some(InsertTextMode::ADJUST_INDENTATION), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: replace_range.clone(), + new_text: build_hook_emit_snippet(&name, callback_params, include_gamemode_arg), + })), + ..Default::default() + }); + } + + builder.get_completion_items_mut().len() > before_count +} + fn net_message_label_detail(registration_count: usize, receiver_count: usize) -> String { format!( "({}, {})", @@ -805,6 +879,34 @@ fn build_hook_add_snippet(hook_name: &str, callback_params: &[String]) -> String ) } +fn build_hook_emit_snippet( + hook_name: &str, + callback_params: &[String], + include_gamemode_arg: bool, +) -> String { + let mut args = Vec::with_capacity(callback_params.len() + usize::from(include_gamemode_arg)); + let mut placeholder_index = 1; + if include_gamemode_arg { + args.push(format!("${{{placeholder_index}:GAMEMODE}}")); + placeholder_index += 1; + } + + for param in callback_params { + args.push(format!( + "${{{}:{}}}", + placeholder_index, + escape_snippet_text(param) + )); + placeholder_index += 1; + } + + if args.is_empty() { + format!("{}\")", escape_snippet_text(hook_name)) + } else { + format!("{}\", {})", escape_snippet_text(hook_name), args.join(", ")) + } +} + fn build_net_receive_snippet(message_name: &str, call_realm: GmodRealm) -> String { let callback_signature = if call_realm == GmodRealm::Client { "function(len)" @@ -819,6 +921,10 @@ fn build_net_receive_snippet(message_name: &str, call_realm: GmodRealm) -> Strin ) } +fn completion_sort_key(name: &str) -> String { + name.to_lowercase() +} + fn escape_snippet_text(text: &str) -> String { text.replace('\\', "\\\\") .replace('$', "\\$") diff --git a/crates/glua_ls/src/handlers/test/code_actions_test.rs b/crates/glua_ls/src/handlers/test/code_actions_test.rs index 34c75c60..37e53483 100644 --- a/crates/glua_ls/src/handlers/test/code_actions_test.rs +++ b/crates/glua_ls/src/handlers/test/code_actions_test.rs @@ -1,8 +1,15 @@ #[cfg(test)] mod tests { - use crate::handlers::test_lib::{ProviderVirtualWorkspace, VirtualCodeAction, check}; + use crate::handlers::{ + code_actions::code_action, + test_lib::{ProviderVirtualWorkspace, VirtualCodeAction, check}, + }; use glua_code_analysis::{DiagnosticCode, Emmyrc}; use googletest::prelude::*; + use lsp_types::CodeActionOrCommand; + use tokio_util::sync::CancellationToken; + + const GMOD_NULL_QUICK_FIX_TITLE: &str = "Use IsValid(...) for GMod NULL check"; #[gtest] fn test_1() -> Result<()> { @@ -87,4 +94,165 @@ mod tests { Ok(()) } + + #[gtest] + fn test_gmod_null_check_code_action_replaces_not_nil_comparison() -> Result<()> { + let edit = gmod_null_quick_fix_result( + r#" + local ent = GetEntityOrNULL() + if ent ~= nil then + ent:GetPos() + end + "#, + )?; + + verify_that!(edit.new_text, eq("IsValid(ent)"))?; + verify_that!( + edit.applied_text, + contains_substring("if IsValid(ent) then") + ) + } + + #[gtest] + fn test_gmod_null_check_code_action_replaces_eq_nil_comparison() -> Result<()> { + let edit = gmod_null_quick_fix_result( + r#" + local ent = GetEntityOrNULL() + if ent == nil then + return + end + "#, + )?; + + verify_that!(edit.new_text, eq("not IsValid(ent)"))?; + verify_that!( + edit.applied_text, + contains_substring("if not IsValid(ent) then") + ) + } + + #[gtest] + fn test_gmod_null_check_code_action_handles_parenthesized_nil_comparison() -> Result<()> { + let edit = gmod_null_quick_fix_result( + r#" + local ent = GetEntityOrNULL() + if ent ~= (nil) then + ent:GetPos() + end + "#, + )?; + + verify_that!(edit.new_text, eq("IsValid(ent)"))?; + verify_that!( + edit.applied_text, + contains_substring("if IsValid(ent) then") + ) + } + + #[gtest] + fn test_gmod_null_check_code_action_wraps_truthy_check() -> Result<()> { + let edit = gmod_null_quick_fix_result( + r#" + local ent = GetEntityOrNULL() + if ent then + ent:GetPos() + end + "#, + )?; + + verify_that!(edit.new_text, eq("IsValid(ent)"))?; + verify_that!( + edit.applied_text, + contains_substring("if IsValid(ent) then") + ) + } + + struct QuickFixResult { + new_text: String, + applied_text: String, + } + + fn gmod_null_quick_fix_result(code: &str) -> Result { + let mut ws = gmod_null_workspace(); + let file_id = ws.def(code); + let diagnostics = check!( + ws.analysis + .diagnose_file(file_id, CancellationToken::new()) + .ok_or("failed to diagnose file") + ); + let actions = check!( + code_action(&ws.analysis, file_id, diagnostics).ok_or("failed to generate code action") + ); + + let edit = check!( + actions + .iter() + .find_map(gmod_null_quick_fix_text_edit) + .ok_or("missing GMod NULL quick fix") + ); + let document = check!( + ws.analysis + .compilation + .get_db() + .get_vfs() + .get_document(&file_id) + .ok_or("missing test document") + ); + let edit_range = check!( + document + .to_rowan_range(edit.range) + .ok_or("failed to convert edit range") + ); + let source = document.get_text(); + let applied_text = format!( + "{}{}{}", + &source[..u32::from(edit_range.start()) as usize], + edit.new_text, + &source[u32::from(edit_range.end()) as usize..] + ); + + Ok(QuickFixResult { + new_text: edit.new_text, + applied_text, + }) + } + + fn gmod_null_quick_fix_text_edit(action: &CodeActionOrCommand) -> Option { + match action { + CodeActionOrCommand::CodeAction(action) + if action.title == GMOD_NULL_QUICK_FIX_TITLE => + { + action + .edit + .as_ref()? + .changes + .as_ref()? + .values() + .next()? + .first() + .cloned() + } + _ => None, + } + } + + fn gmod_null_workspace() -> ProviderVirtualWorkspace { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.analysis.update_config(emmyrc.into()); + ws.def( + r#" + ---@class Entity + ---@field GetPos fun(self: Entity): any + + ---@class NULL : Entity + ---@alias EntityOrNULL Entity|NULL + + ---@return EntityOrNULL + function GetEntityOrNULL() end + "#, + ); + ws + } } diff --git a/crates/glua_ls/src/handlers/test/completion_test.rs b/crates/glua_ls/src/handlers/test/completion_test.rs index 90409159..9929c5dd 100644 --- a/crates/glua_ls/src/handlers/test/completion_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_test.rs @@ -1,11 +1,18 @@ #[cfg(test)] mod tests { - use glua_code_analysis::{DocSyntax, Emmyrc, EmmyrcFilenameConvention}; + use glua_code_analysis::{ + DocSyntax, Emmyrc, EmmyrcFilenameConvention, FileId, file_path_to_uri, + }; use googletest::prelude::*; use lsp_types::{ CompletionItemKind, CompletionResponse, CompletionTriggerKind, Documentation, InsertTextFormat, MarkupContent, }; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; use tokio_util::sync::CancellationToken; use crate::handlers::completion::completion; @@ -3292,6 +3299,141 @@ mod tests { )?; verify_that!(text_edit.range.start.line, eq(1))?; verify_that!(text_edit.range.start.character, eq(22))?; + verify_that!(item.filter_text.as_deref(), eq(Some("PlayerSpawn")))?; + verify_that!( + item.sort_text + .as_deref() + .is_some_and(|sort_text| sort_text.starts_with("000_gmod_hook_add_")), + eq(true) + )?; + verify_that!(item.insert_text_format, eq(Some(InsertTextFormat::SNIPPET)))?; + + Ok(()) + } + + #[gtest] + fn test_gmod_hook_run_string_completion_expands_emit_snippet() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.analysis.update_config(emmyrc.into()); + ws.def( + r#" + function GM:PlayerSpawn(ply, transition) end + "#, + ); + + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + hook.Run("") + "#, + )?; + let file_id = ws.def(content.as_str()); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + + let item = items + .iter() + .find(|item| item.label == "PlayerSpawn") + .ok_or("missing PlayerSpawn completion") + .or_fail()?; + let text_edit = item + .text_edit + .as_ref() + .ok_or("missing staged hook.Run text edit") + .or_fail()?; + let lsp_types::CompletionTextEdit::Edit(text_edit) = text_edit else { + return fail!("expected text edit for staged hook.Run completion"); + }; + + verify_eq!(item.kind, Some(CompletionItemKind::EVENT))?; + verify_that!( + text_edit.new_text.as_str(), + eq("PlayerSpawn\", ${1:ply}, ${2:transition})") + )?; + verify_that!(text_edit.range.start.line, eq(1))?; + verify_that!(text_edit.range.start.character, eq(22))?; + verify_that!(item.filter_text.as_deref(), eq(Some("PlayerSpawn")))?; + verify_that!( + item.sort_text + .as_deref() + .is_some_and(|sort_text| sort_text.starts_with("000_gmod_hook_emit_")), + eq(true) + )?; + verify_that!(item.insert_text_format, eq(Some(InsertTextFormat::SNIPPET)))?; + + Ok(()) + } + + #[gtest] + fn test_gmod_hook_call_string_completion_expands_emit_snippet_with_gamemode() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.analysis.update_config(emmyrc.into()); + ws.def( + r#" + function GM:PlayerSpawn(ply, transition) end + "#, + ); + + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + hook.Call("") + "#, + )?; + let file_id = ws.def(content.as_str()); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + + let item = items + .iter() + .find(|item| item.label == "PlayerSpawn") + .ok_or("missing PlayerSpawn completion") + .or_fail()?; + let text_edit = item + .text_edit + .as_ref() + .ok_or("missing staged hook.Call text edit") + .or_fail()?; + let lsp_types::CompletionTextEdit::Edit(text_edit) = text_edit else { + return fail!("expected text edit for staged hook.Call completion"); + }; + + verify_eq!(item.kind, Some(CompletionItemKind::EVENT))?; + verify_that!( + text_edit.new_text.as_str(), + eq("PlayerSpawn\", ${1:GAMEMODE}, ${2:ply}, ${3:transition})") + )?; + verify_that!(item.filter_text.as_deref(), eq(Some("PlayerSpawn")))?; + verify_that!( + item.sort_text + .as_deref() + .is_some_and(|sort_text| sort_text.starts_with("000_gmod_hook_emit_")), + eq(true) + )?; verify_that!(item.insert_text_format, eq(Some(InsertTextFormat::SNIPPET)))?; Ok(()) @@ -4256,4 +4398,110 @@ mod tests { Ok(()) } + + #[gtest] + fn test_gmod_include_path_completion_filters_to_lua_files() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.analysis.update_config(emmyrc.into()); + let root = create_path_completion_root("gmod_include_path")?; + fs::create_dir_all(root.join("lua/includes")).or_fail()?; + fs::write(root.join("lua/includes/init.lua"), "").or_fail()?; + fs::write(root.join("lua/includes/icon.png"), "").or_fail()?; + ws.analysis.add_main_workspace(root.clone()); + + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + include("lua/includes/") + "#, + )?; + let file_id = define_disk_file(&mut ws, root.join("lua/autorun/test.lua"), &content)?; + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + + verify_that!(items.iter().any(|item| item.label == "init.lua"), eq(true))?; + verify_that!(items.iter().any(|item| item.label == "icon.png"), eq(false)) + } + + #[gtest] + fn test_member_named_include_uses_annotation_path_kind() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let root = create_path_completion_root("member_include_path")?; + fs::create_dir_all(root.join("materials/icons")).or_fail()?; + fs::write(root.join("materials/icons/icon.png"), "").or_fail()?; + fs::write(root.join("materials/icons/cl_init.lua"), "").or_fail()?; + ws.analysis.add_main_workspace(root.clone()); + ws.def( + r#" + ---@class Loader + ---@field include fun(self: Loader, path: Path) + ---@type Loader + local loader + "#, + ); + + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + loader:include("materials/icons/") + "#, + )?; + let file_id = define_disk_file(&mut ws, root.join("lua/autorun/test.lua"), &content)?; + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + + verify_that!(items.iter().any(|item| item.label == "icon.png"), eq(true))?; + verify_that!( + items.iter().any(|item| item.label == "cl_init.lua"), + eq(true) + ) + } + + fn create_path_completion_root(name: &str) -> Result { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .or_fail()? + .as_nanos(); + let root = std::env::temp_dir().join(format!("glua_ls_{name}_{nonce}")); + fs::create_dir_all(&root).or_fail()?; + Ok(root) + } + + fn define_disk_file( + ws: &mut ProviderVirtualWorkspace, + path: PathBuf, + content: &str, + ) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).or_fail()?; + } + let uri = file_path_to_uri(&path) + .ok_or("failed to create file URI") + .or_fail()?; + ws.analysis + .update_file_by_uri(&uri, Some(content.to_string())) + .or_fail() + } } From e3aa1be6090e6a2856cc3156f8a368be3f3ce34a Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 31 May 2026 20:06:53 +0100 Subject: [PATCH 6/8] feat: show color previews in autocomplete and hover Co-Authored-By: Claude Opus 4.8 (1M context) --- .../glua_code_analysis/resources/schema.json | 2 +- .../src/config/configs/document_color.rs | 2 +- .../add_completions/add_decl_completion.rs | 5 +- .../add_completions/add_member_completion.rs | 5 +- .../add_completions/completion_item_info.rs | 32 +++- .../completion/add_completions/mod.rs | 4 + .../handlers/completion/completion_data.rs | 1 + crates/glua_ls/src/handlers/completion/mod.rs | 1 + .../handlers/completion/resolve_completion.rs | 43 +++--- .../handlers/document_color/build_color.rs | 42 +++++- .../src/handlers/document_color/mod.rs | 138 ++++++++++++++++-- .../emmy_annotator/emmy_annotator_request.rs | 9 +- .../src/handlers/emmy_annotator/mod.rs | 5 +- .../glua_ls/src/handlers/hover/build_hover.rs | 113 ++++++++++++++ .../src/handlers/hover/color_swatch.rs | 126 ++++++++++++++++ crates/glua_ls/src/handlers/hover/mod.rs | 1 + .../src/handlers/test/completion_test.rs | 31 ++-- .../glua_ls/src/handlers/test/hover_test.rs | 76 ++++++++++ 18 files changed, 569 insertions(+), 67 deletions(-) create mode 100644 crates/glua_ls/src/handlers/hover/color_swatch.rs diff --git a/crates/glua_code_analysis/resources/schema.json b/crates/glua_code_analysis/resources/schema.json index b89a5b03..58be5552 100644 --- a/crates/glua_code_analysis/resources/schema.json +++ b/crates/glua_code_analysis/resources/schema.json @@ -630,7 +630,7 @@ "properties": { "enable": { "default": true, - "description": "Enable parsing strings for color tags and showing a color picker next to them.", + "description": "Enable color previews and color picker support for strings, GMod color calls, color tuples, and color variables.", "type": "boolean", "x-vscode-setting": true } diff --git a/crates/glua_code_analysis/src/config/configs/document_color.rs b/crates/glua_code_analysis/src/config/configs/document_color.rs index 06926b3e..b74cc75c 100644 --- a/crates/glua_code_analysis/src/config/configs/document_color.rs +++ b/crates/glua_code_analysis/src/config/configs/document_color.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] #[serde(rename_all = "camelCase")] pub struct EmmyrcDocumentColor { - /// Enable parsing strings for color tags and showing a color picker next to them. + /// Enable color previews and color picker support for strings, GMod color calls, color tuples, and color variables. #[serde(default = "default_true")] #[schemars(extend("x-vscode-setting" = true))] pub enable: bool, diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs index 7f41b017..68ba8ce6 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs @@ -9,7 +9,7 @@ use crate::handlers::completion::{ }; use super::{ - CallDisplay, check_visibility, + CallDisplay, check_visibility, color_label_detail, completion_item_info::{ color_info_from_expr, color_info_from_type, gmod_constructor_literal_detail, is_color_type, is_gmod_literal_constructor_type, scalar_literal_description, scalar_literal_detail, @@ -50,7 +50,7 @@ pub fn add_decl_completion( label_details: Some(lsp_types::CompletionItemLabelDetails { detail: color .as_ref() - .map(|color| format!(" {}", color.hex)) + .map(color_label_detail) .or_else(|| get_detail(builder, typ, CallDisplay::None)) .or(constructor_literal_detail) .or(literal_detail), @@ -62,7 +62,6 @@ pub fn add_decl_completion( }), ..Default::default() }; - if is_deprecated(builder, property_owner.clone()) { completion_item.deprecated = Some(true); } diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs index 4f973ed3..7eafa848 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs @@ -16,7 +16,7 @@ use crate::handlers::completion::{ }; use super::{ - CallDisplay, check_visibility, + CallDisplay, check_visibility, color_label_detail, completion_item_info::{ color_info_from_expr, color_info_from_type, gmod_constructor_literal_detail, is_color_type, is_gmod_literal_constructor_type, scalar_literal_description, scalar_literal_detail, @@ -138,7 +138,7 @@ pub fn add_member_completion_with_description_hint( // 紧靠着 label 显示的描述 let literal_detail = scalar_literal_detail(&remove_nil_type); let detail = if let Some(color) = &color { - Some(format!(" {}", color.hex)) + Some(color_label_detail(color)) } else if is_inferred_dynamic_member { None } else { @@ -178,7 +178,6 @@ pub fn add_member_completion_with_description_hint( deprecated, ..Default::default() }; - if status == CompletionTriggerStatus::Dot && member_key.is_integer() && builder.trigger_token.kind() == LuaTokenKind::TkDot.into() diff --git a/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs b/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs index efd9e834..5b4fd37a 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs @@ -8,7 +8,7 @@ use crate::handlers::{ }, }; -pub(super) fn color_info_from_type(typ: &LuaType) -> Option { +pub(crate) fn color_info_from_type(typ: &LuaType) -> Option { let text = match typ { LuaType::StringConst(text) | LuaType::DocStringConst(text) => text.as_str(), _ => return None, @@ -24,12 +24,18 @@ pub(super) fn color_info_from_type(typ: &LuaType) -> Option Some(completion_color_info(hex_color.color, hex_color.has_alpha)) } -pub(super) fn color_info_from_expr(expr: &LuaExpr) -> Option { +pub(crate) fn color_info_from_expr(expr: &LuaExpr) -> Option { match expr { LuaExpr::CallExpr(call_expr) => { let color = gmod_color_from_call_expr(call_expr)?; let alpha = (color.alpha * 255.0).round() as u8; - Some(completion_color_info(color, alpha != u8::MAX)) + let has_explicit_alpha = call_expr + .get_args_list() + .is_some_and(|args| args.get_args().count() >= 4); + Some(completion_color_info( + color, + has_explicit_alpha || alpha != u8::MAX, + )) } LuaExpr::LiteralExpr(literal_expr) => { let LuaLiteralToken::String(token) = literal_expr.get_literal()? else { @@ -120,7 +126,7 @@ pub(super) fn is_gmod_literal_constructor_type(typ: &LuaType) -> bool { } } -pub(super) fn is_color_type(typ: &LuaType) -> bool { +pub(crate) fn is_color_type(typ: &LuaType) -> bool { match typ { LuaType::Ref(id) | LuaType::Def(id) => id.get_simple_name() == "Color", LuaType::Instance(instance) => is_color_type(instance.get_base()), @@ -133,6 +139,14 @@ pub(super) fn is_color_type(typ: &LuaType) -> bool { } } +pub(crate) fn color_label_detail(color: &CompletionColorInfo) -> String { + format!(" {}", color.gmod_display) +} + +pub(crate) fn color_preview_documentation(color: &CompletionColorInfo) -> String { + format!("`{}`", color.gmod_display) +} + fn is_gmod_literal_constructor_name(name: &str) -> bool { matches!(name, "Vector" | "Angle") } @@ -155,16 +169,21 @@ fn numeric_literal_text(expr: &LuaExpr) -> Option { } } -fn completion_color_info(color: GmodColor, include_alpha_in_hex: bool) -> CompletionColorInfo { +fn completion_color_info(color: GmodColor, include_alpha: bool) -> CompletionColorInfo { let red = (color.red * 255.0).round() as u8; let green = (color.green * 255.0).round() as u8; let blue = (color.blue * 255.0).round() as u8; let alpha = (color.alpha * 255.0).round() as u8; - let hex = if include_alpha_in_hex { + let hex = if include_alpha { format!("#{:02X}{:02X}{:02X}{:02X}", red, green, blue, alpha) } else { format!("#{:02X}{:02X}{:02X}", red, green, blue) }; + let gmod_display = if include_alpha { + format!("Color({}, {}, {}, {})", red, green, blue, alpha) + } else { + format!("Color({}, {}, {})", red, green, blue) + }; CompletionColorInfo { red, @@ -174,6 +193,7 @@ fn completion_color_info(color: GmodColor, include_alpha_in_hex: bool) -> Comple rgb: format!("rgb({}, {}, {})", red, green, blue), rgba: format!("rgba({}, {}, {}, {})", red, green, blue, alpha), gmod: format!("Color({}, {}, {}, {})", red, green, blue, alpha), + gmod_display, hex, } } diff --git a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs index 2dfd03c9..59a90b8e 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs @@ -9,6 +9,10 @@ pub use add_member_completion::{ CompletionTriggerStatus, add_member_completion_with_description_hint, }; pub use check_match_word::check_match_word; +pub(crate) use completion_item_info::{ + color_info_from_expr, color_info_from_type, color_label_detail, color_preview_documentation, + is_color_type, +}; use glua_code_analysis::{LuaSemanticDeclId, LuaType, RenderLevel}; use lsp_types::CompletionItemKind; diff --git a/crates/glua_ls/src/handlers/completion/completion_data.rs b/crates/glua_ls/src/handlers/completion/completion_data.rs index 6e3473f8..8974a6df 100644 --- a/crates/glua_ls/src/handlers/completion/completion_data.rs +++ b/crates/glua_ls/src/handlers/completion/completion_data.rs @@ -27,6 +27,7 @@ pub struct CompletionColorInfo { pub rgb: String, pub rgba: String, pub gmod: String, + pub gmod_display: String, } #[allow(unused)] diff --git a/crates/glua_ls/src/handlers/completion/mod.rs b/crates/glua_ls/src/handlers/completion/mod.rs index 42b7b379..5f2eb565 100644 --- a/crates/glua_ls/src/handlers/completion/mod.rs +++ b/crates/glua_ls/src/handlers/completion/mod.rs @@ -6,6 +6,7 @@ mod providers; mod resolve_completion; pub use add_completions::get_index_alias_name; +pub(crate) use add_completions::{color_info_from_expr, color_info_from_type, is_color_type}; use completion_builder::CompletionBuilder; pub(crate) use completion_data::CompletionData; #[cfg(test)] diff --git a/crates/glua_ls/src/handlers/completion/resolve_completion.rs b/crates/glua_ls/src/handlers/completion/resolve_completion.rs index 7dee1eec..62bb55dd 100644 --- a/crates/glua_ls/src/handlers/completion/resolve_completion.rs +++ b/crates/glua_ls/src/handlers/completion/resolve_completion.rs @@ -6,7 +6,10 @@ use crate::{ handlers::hover::{HoverBuilder, build_hover_content_for_completion}, }; -use super::completion_data::{CompletionColorInfo, CompletionData, CompletionDataType}; +use super::{ + add_completions::color_preview_documentation, + completion_data::{CompletionColorInfo, CompletionData, CompletionDataType}, +}; pub fn resolve_completion( compilation: &LuaCompilation, @@ -196,35 +199,25 @@ fn apply_color_completion_documentation( completion_item: &mut CompletionItem, color: &CompletionColorInfo, ) { - let mut color_documentation = String::new(); - color_documentation.push_str("\n\n---\n\n"); - color_documentation.push_str("**Color preview**\n\n"); - let color_title = format!( - "{} | {} | {} | {}", - color.hex, color.rgb, color.rgba, color.gmod - ); - color_documentation.push_str(&format!( - concat!( - "", - " {}\n\n" - ), - color_title, color.hex, color.hex - )); - color_documentation.push_str(&format!( - "`{}` \n`{}` \n`{}`", - color.rgb, color.rgba, color.gmod - )); + let preview_documentation = color_preview_documentation(color); match completion_item.documentation.take() { Some(Documentation::MarkupContent(mut markup)) => { - markup.value.push_str(&color_documentation); + if !markup.value.contains(&preview_documentation) { + if !markup.value.trim().is_empty() { + markup.value.push_str("\n\n"); + } + markup.value.push_str(&preview_documentation); + } completion_item.documentation = Some(Documentation::MarkupContent(markup)); } Some(Documentation::String(mut text)) => { - text.push_str(&color_documentation); + if !text.contains(&preview_documentation) { + if !text.trim().is_empty() { + text.push_str("\n\n"); + } + text.push_str(&preview_documentation); + } completion_item.documentation = Some(Documentation::MarkupContent(MarkupContent { kind: lsp_types::MarkupKind::Markdown, value: text, @@ -233,7 +226,7 @@ fn apply_color_completion_documentation( None => { completion_item.documentation = Some(Documentation::MarkupContent(MarkupContent { kind: lsp_types::MarkupKind::Markdown, - value: color_documentation.trim_start().to_string(), + value: preview_documentation, })); } } diff --git a/crates/glua_ls/src/handlers/document_color/build_color.rs b/crates/glua_ls/src/handlers/document_color/build_color.rs index 79864b98..1d388275 100644 --- a/crates/glua_ls/src/handlers/document_color/build_color.rs +++ b/crates/glua_ls/src/handlers/document_color/build_color.rs @@ -387,12 +387,11 @@ pub fn convert_color_to_hex(color: Color, len: usize) -> String { mod tests { use std::path::PathBuf; + use super::build_colors; use glua_code_analysis::{FileId, VirtualWorkspace}; use glua_parser::{LineIndex, LuaAstNode, LuaParser, ParserConfig}; use googletest::prelude::*; - use super::build_colors; - fn collect_colors(text: &str) -> Vec { let tree = LuaParser::parse(text, ParserConfig::default()); let line_index = LineIndex::parse(text); @@ -468,6 +467,45 @@ mod tests { Ok(()) } + #[gtest] + fn document_colors_do_not_include_color_variable_references() -> Result<()> { + let colors = collect_colors_semantic( + r#" + ---@param r number + ---@param g number + ---@param b number + ---@param a? number + function Color(r, g, b, a) end + + local c = Color(0, 0, 255) + print(c) + "#, + ); + + verify_that!(colors.len(), eq(1))?; + Ok(()) + } + + #[gtest] + fn document_colors_do_not_include_color_member_references() -> Result<()> { + let colors = collect_colors_semantic( + r#" + ---@param r number + ---@param g number + ---@param b number + ---@param a? number + function Color(r, g, b, a) end + + T = {} + T.Blue = Color(0, 0, 255) + print(T.Blue) + "#, + ); + + verify_that!(colors.len(), eq(1))?; + Ok(()) + } + #[gtest] fn ignores_non_color_tuples() -> Result<()> { let colors = collect_colors_semantic( diff --git a/crates/glua_ls/src/handlers/document_color/mod.rs b/crates/glua_ls/src/handlers/document_color/mod.rs index 0213ab84..8ea49801 100644 --- a/crates/glua_ls/src/handlers/document_color/mod.rs +++ b/crates/glua_ls/src/handlers/document_color/mod.rs @@ -1,6 +1,6 @@ pub(crate) mod build_color; -use build_color::{build_colors, convert_color_to_hex}; +use build_color::{build_colors, convert_color_to_hex, gmod_color_from_hex_text}; use glua_code_analysis::SemanticModel; use glua_parser::{LuaAstNode, LuaCallExpr}; use lsp_types::{ @@ -51,6 +51,26 @@ fn convert_color_to_tuple(color: Color, original_text: &str, arity: Option Option { + let mut count = 0; + for component in text.split(',') { + let component = component.trim(); + if component.is_empty() { + return None; + } + let value = component.parse::().ok()?; + if !(0.0..=255.0).contains(&value) { + return None; + } + count += 1; + } + + match count { + 3 | 4 => Some(count), + _ => None, + } +} + pub async fn on_document_color( context: ServerContextSnapshot, params: DocumentColorParams, @@ -189,6 +209,26 @@ fn check_call_for_exact_tuple_range( } } +fn color_presentation_text( + semantic_model: &SemanticModel, + range: rowan::TextRange, + color: Color, + text: &str, +) -> Option { + if is_gmod_color_call(text) { + Some(convert_color_to_gmod(color, text)) + } else if text.contains(',') { + let arity = get_color_tuple_arity(semantic_model, range) + .or_else(|| parse_numeric_color_tuple_arity(text)); + let arity = arity?; + Some(convert_color_to_tuple(color, text, Some(arity))) + } else if gmod_color_from_hex_text(text).is_some() { + Some(convert_color_to_hex(color, text.len())) + } else { + None + } +} + pub async fn on_document_color_presentation( context: ServerContextSnapshot, params: ColorPresentationParams, @@ -219,13 +259,8 @@ pub async fn on_document_color_presentation( }; let color = params.color; let text = document.get_text_slice(range); - let color_text = if is_gmod_color_call(text) { - convert_color_to_gmod(color, text) - } else if text.contains(',') { - let arity = get_color_tuple_arity(&semantic_model, range); - convert_color_to_tuple(color, text, arity) - } else { - convert_color_to_hex(color, text.len()) + let Some(color_text) = color_presentation_text(&semantic_model, range, color, text) else { + return vec![]; }; let color_presentations = vec![ColorPresentation { label: text.to_string(), @@ -250,7 +285,8 @@ impl RegisterCapabilities for DocumentColorCapabilities { #[cfg(test)] mod tests { use super::{ - convert_color_to_gmod, convert_color_to_tuple, get_color_tuple_arity, is_gmod_color_call, + color_presentation_text, convert_color_to_gmod, convert_color_to_tuple, + get_color_tuple_arity, is_gmod_color_call, parse_numeric_color_tuple_arity, }; use glua_parser::LuaAstNode; use lsp_types::Color; @@ -278,6 +314,33 @@ mod tests { get_color_tuple_arity(&semantic_model, range) } + fn get_color_presentation_text_for_last_color(text: &str) -> Option { + let mut ws = glua_code_analysis::VirtualWorkspace::new(); + let file_id = ws.def(text); + let semantic_model = ws.analysis.compilation.get_semantic_model(file_id).unwrap(); + let doc = semantic_model.get_document(); + let root = semantic_model.get_root(); + let colors = crate::handlers::document_color::build_color::build_colors( + root.syntax().clone(), + &doc, + Some(&semantic_model), + ); + let color_info = colors.last()?; + let range = doc.to_rowan_range(color_info.range)?; + let original_text = doc.get_text_slice(range); + color_presentation_text( + &semantic_model, + range, + Color { + red: 1.0, + green: 0.0, + blue: 0.0, + alpha: 1.0, + }, + original_text, + ) + } + #[test] fn test_get_color_tuple_arity() { let arity_4 = get_semantic_tuple_arity( @@ -410,4 +473,61 @@ mod tests { "255, 0, 0, 255" ); } + + #[test] + fn test_parse_numeric_color_tuple_arity() { + assert_eq!(parse_numeric_color_tuple_arity("255, 0, 128"), Some(3)); + assert_eq!(parse_numeric_color_tuple_arity("255, 0, 128, 64"), Some(4)); + assert_eq!(parse_numeric_color_tuple_arity("foo, bar, baz"), None); + assert_eq!(parse_numeric_color_tuple_arity("255, 0"), None); + assert_eq!(parse_numeric_color_tuple_arity("255, 0, 256"), None); + } + + #[test] + fn test_color_reference_presentation_is_read_only() { + let mut ws = glua_code_analysis::VirtualWorkspace::new(); + let file_id = ws.def("local color = 1"); + let semantic_model = ws.analysis.compilation.get_semantic_model(file_id).unwrap(); + let presentation_text = color_presentation_text( + &semantic_model, + rowan::TextRange::new(rowan::TextSize::new(6), rowan::TextSize::new(11)), + Color { + red: 1.0, + green: 0.0, + blue: 0.0, + alpha: 1.0, + }, + "color", + ); + + assert_eq!(presentation_text, None); + } + + #[test] + fn test_hex_color_presentation_is_editable() { + let presentation_text = get_color_presentation_text_for_last_color(r##"print("#0000FF")"##); + + assert_eq!(presentation_text, Some("#FF0000".to_string())); + } + + #[test] + fn test_invalid_tuple_presentation_is_read_only() { + let mut ws = glua_code_analysis::VirtualWorkspace::new(); + let file_id = ws.def("local a, b, c = 1, 2, 3"); + let semantic_model = ws.analysis.compilation.get_semantic_model(file_id).unwrap(); + let range = rowan::TextRange::new(rowan::TextSize::new(0), rowan::TextSize::new(9)); + let presentation_text = color_presentation_text( + &semantic_model, + range, + Color { + red: 1.0, + green: 0.0, + blue: 0.0, + alpha: 1.0, + }, + "foo, bar, baz", + ); + + assert_eq!(presentation_text, None); + } } diff --git a/crates/glua_ls/src/handlers/emmy_annotator/emmy_annotator_request.rs b/crates/glua_ls/src/handlers/emmy_annotator/emmy_annotator_request.rs index 820ad621..71457b55 100644 --- a/crates/glua_ls/src/handlers/emmy_annotator/emmy_annotator_request.rs +++ b/crates/glua_ls/src/handlers/emmy_annotator/emmy_annotator_request.rs @@ -6,7 +6,7 @@ pub enum EmmyAnnotatorRequest {} impl Request for EmmyAnnotatorRequest { type Params = EmmyAnnotatorParams; - type Result = Option>; + type Result = Option; const METHOD: &'static str = "gluals/annotator"; } @@ -15,6 +15,13 @@ pub struct EmmyAnnotatorParams { pub uri: String, } +/// Annotator response: style annotators (read-only/mutable underlines, doc +/// emphasis). +#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)] +pub struct EmmyAnnotatorResult { + pub annotators: Vec, +} + #[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)] pub struct EmmyAnnotator { #[serde(rename = "type")] diff --git a/crates/glua_ls/src/handlers/emmy_annotator/mod.rs b/crates/glua_ls/src/handlers/emmy_annotator/mod.rs index 04938835..9caafbff 100644 --- a/crates/glua_ls/src/handlers/emmy_annotator/mod.rs +++ b/crates/glua_ls/src/handlers/emmy_annotator/mod.rs @@ -14,7 +14,7 @@ pub async fn on_emmy_annotator_handler( context: ServerContextSnapshot, params: EmmyAnnotatorParams, cancel_token: CancellationToken, -) -> Option> { +) -> Option { if cancel_token.is_cancelled() { return None; } @@ -43,7 +43,8 @@ pub async fn on_emmy_annotator_handler( let file_id = analysis.get_file_id(&uri)?; let semantic_model = analysis.compilation.get_semantic_model(file_id)?; - build_annotators(&semantic_model) + let annotators = build_annotators(&semantic_model); + EmmyAnnotatorResult { annotators } }; Some(result) diff --git a/crates/glua_ls/src/handlers/hover/build_hover.rs b/crates/glua_ls/src/handlers/hover/build_hover.rs index 5865530e..c9856a7a 100644 --- a/crates/glua_ls/src/handlers/hover/build_hover.rs +++ b/crates/glua_ls/src/handlers/hover/build_hover.rs @@ -13,11 +13,13 @@ use glua_parser::{ use lsp_types::{Hover, HoverContents, MarkedString, MarkupContent}; use rowan::{TextRange, TextSize}; +use crate::handlers::completion::{color_info_from_expr, color_info_from_type, is_color_type}; use crate::handlers::hover::function::{build_function_hover, is_function}; use crate::handlers::hover::humanize_type_decl::build_type_decl_hover; use crate::handlers::hover::humanize_types::hover_humanize_type; use super::{ + color_swatch::color_swatch_markdown, find_origin::{find_decl_origin_owners, find_member_origin_owners}, hover_builder::HoverBuilder, humanize_types::hover_const_type, @@ -321,6 +323,9 @@ fn build_decl_hover( if let Some(desc) = get_gmod_class_description(db, &typ) { builder.add_annotation_description(desc); } + if !is_completion { + add_decl_color_preview(builder, db, &typ, decl_id); + } if let LuaDeclExtra::Param { idx, signature_id, .. @@ -470,10 +475,118 @@ fn build_member_hover( if let Some(desc) = get_gmod_class_description(db, &typ) { builder.add_annotation_description(desc); } + if !is_completion { + add_member_color_preview(builder, db, &typ, member_id); + } Some(()) } +fn add_decl_color_preview( + builder: &mut HoverBuilder, + db: &DbIndex, + typ: &LuaType, + decl_id: LuaDeclId, +) -> Option<()> { + if !db.get_emmyrc().document_color.enable { + return None; + } + let color = color_info_from_type(typ).or_else(|| { + if !is_color_type(typ) && !matches!(typ, LuaType::Unknown) { + return None; + } + let decl = db.get_decl_index().get_decl(&decl_id)?; + let value_syntax_id = decl.get_value_syntax_id()?; + if !can_hover_value_expr_be_color(value_syntax_id.get_kind()) { + return None; + } + let tree = db.get_vfs().get_syntax_tree(&decl_id.file_id)?; + let value_node = value_syntax_id.to_node_from_root(&tree.get_red_root())?; + LuaExpr::cast(value_node).and_then(|expr| color_info_from_expr(&expr)) + })?; + let decl = db.get_decl_index().get_decl(&decl_id)?; + let prefix = if decl.is_local() { + "local " + } else { + "(global) " + }; + builder.set_type_description(format!( + "{}{}: {}", + prefix, + decl.get_name(), + color.gmod_display + )); + builder.add_annotation_description(color_swatch_markdown( + color.red, + color.green, + color.blue, + color.alpha, + )); + Some(()) +} + +fn add_member_color_preview( + builder: &mut HoverBuilder, + db: &DbIndex, + typ: &LuaType, + member_id: LuaMemberId, +) -> Option<()> { + if !db.get_emmyrc().document_color.enable { + return None; + } + let color = color_info_from_type(typ).or_else(|| { + if !is_color_type(typ) && !matches!(typ, LuaType::Unknown) { + return None; + } + get_member_value_expr(db, member_id).and_then(|expr| color_info_from_expr(&expr)) + })?; + let member = db.get_member_index().get_member(&member_id)?; + let member_name: &str = match member.get_key() { + LuaMemberKey::Name(name) => name.as_str(), + _ => return None, + }; + builder.set_type_description(format!("(field) {}: {}", member_name, color.gmod_display)); + builder.add_annotation_description(color_swatch_markdown( + color.red, + color.green, + color.blue, + color.alpha, + )); + Some(()) +} + +fn can_hover_value_expr_be_color(kind: LuaSyntaxKind) -> bool { + matches!(kind, LuaSyntaxKind::CallExpr | LuaSyntaxKind::LiteralExpr) +} + +fn get_member_value_expr(db: &DbIndex, member_id: LuaMemberId) -> Option { + let root = db + .get_vfs() + .get_syntax_tree(&member_id.file_id)? + .get_red_root(); + let node = member_id.get_syntax_id().to_node_from_root(&root)?; + + if let Some(field) = LuaTableField::cast(node.clone()) { + return field.get_value_expr(); + } + + if let Some(index_expr) = LuaIndexExpr::cast(node) { + if let Some(assign_stat) = index_expr.get_parent::() { + let (vars, value_exprs) = assign_stat.get_var_and_expr_list(); + let value_idx = vars + .iter() + .position(|var| var.get_syntax_id() == index_expr.get_syntax_id())?; + return value_exprs.get(value_idx).cloned(); + } + + if let Some(func_stat) = index_expr.get_parent::() { + return func_stat.get_closure().map(LuaExpr::ClosureExpr); + } + } + + None +} + fn get_gmod_class_description(db: &DbIndex, typ: &LuaType) -> Option { if !db.get_emmyrc().gmod.enabled { return None; diff --git a/crates/glua_ls/src/handlers/hover/color_swatch.rs b/crates/glua_ls/src/handlers/hover/color_swatch.rs new file mode 100644 index 00000000..9e7c19ea --- /dev/null +++ b/crates/glua_ls/src/handlers/hover/color_swatch.rs @@ -0,0 +1,126 @@ +//! Renders a small color swatch as an inline SVG `data:` URI for use in hover +//! markdown. The hover *type line* lives in a fenced ` ```lua ` block where +//! markdown images do not render, so the swatch is emitted into the hover +//! *description* region (plain markdown), mirroring the realm badge. + +/// Side length of the rendered swatch, in pixels. +const SWATCH_SIZE: u32 = 14; + +/// Builds the description-region markdown for a color swatch: an inline SVG +/// image followed by the `Color(r, g, b[, a])` text. +/// +/// `alpha` is included in the text only when it is not fully opaque. +pub(crate) fn color_swatch_markdown(red: u8, green: u8, blue: u8, alpha: u8) -> String { + let data_uri = color_swatch_data_uri(red, green, blue); + let text = if alpha == u8::MAX { + format!("Color({}, {}, {})", red, green, blue) + } else { + format!("Color({}, {}, {}, {})", red, green, blue, alpha) + }; + format!("![]({}) `{}`", data_uri, text) +} + +/// Builds a `data:image/svg+xml;base64,...` URI for a solid color square. +pub(crate) fn color_swatch_data_uri(red: u8, green: u8, blue: u8) -> String { + let svg = format!( + "\ +", + size = SWATCH_SIZE, + r = red, + g = green, + b = blue, + ); + format!( + "data:image/svg+xml;base64,{}", + base64_encode(svg.as_bytes()) + ) +} + +/// Minimal standard base64 encoder (no padding omission, no line wrapping). +/// Kept local to avoid adding a dependency for a tiny fixed-size payload. +fn base64_encode(input: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let triple = (b0 << 16) | (b1 << 8) | b2; + + out.push(TABLE[((triple >> 18) & 0x3F) as usize] as char); + out.push(TABLE[((triple >> 12) & 0x3F) as usize] as char); + out.push(if chunk.len() > 1 { + TABLE[((triple >> 6) & 0x3F) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + TABLE[(triple & 0x3F) as usize] as char + } else { + '=' + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base64_decode(input: &str) -> Vec { + const TABLE: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let lookup = |c: u8| TABLE.iter().position(|&t| t == c).unwrap() as u32; + let bytes: Vec = input.bytes().filter(|&b| b != b'=').collect(); + let mut out = Vec::new(); + for chunk in bytes.chunks(4) { + let mut buf = 0u32; + for (i, &c) in chunk.iter().enumerate() { + buf |= lookup(c) << (18 - 6 * i); + } + out.push((buf >> 16) as u8); + if chunk.len() > 2 { + out.push((buf >> 8) as u8); + } + if chunk.len() > 3 { + out.push(buf as u8); + } + } + out + } + + #[test] + fn base64_roundtrips_known_vectors() { + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); + assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn data_uri_decodes_to_svg_with_expected_fill() { + let uri = color_swatch_data_uri(255, 0, 0); + let b64 = uri.strip_prefix("data:image/svg+xml;base64,").unwrap(); + let svg = String::from_utf8(base64_decode(b64)).unwrap(); + assert!(svg.contains("fill=\"#FF0000\""), "svg was: {svg}"); + assert!(svg.contains(" Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@class Color + ---@return Color + function Color(r, g, b, a) end + + local HEADLIGHT = Color(255, 231, 176) + HEADLIGHT + "#, + )?; + let file_id = ws.def(&content); + let value = extract_hover_markdown(&ws, file_id, position); + + verify_that!( + value, + contains_substring("local HEADLIGHT: Color(255, 231, 176)") + )?; + // The description region now carries an inline SVG swatch image plus the + // Color(...) text. The type line above remains plain text. + verify_that!(value, contains_substring("data:image/svg+xml;base64,"))?; + verify_that!(value, contains_substring("`Color(255, 231, 176)`"))?; + Ok(()) + } + + #[gtest] + fn test_hover_color_decl_swatch_suppressed_when_disabled() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = ws.get_emmyrc(); + emmyrc.document_color.enable = false; + ws.update_emmyrc(emmyrc); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@class Color + ---@return Color + function Color(r, g, b, a) end + + local HEADLIGHT = Color(255, 231, 176) + HEADLIGHT + "#, + )?; + let file_id = ws.def(&content); + let value = extract_hover_markdown(&ws, file_id, position); + + verify_that!(value, not(contains_substring("data:image/svg+xml;base64,")))?; + Ok(()) + } + + #[gtest] + fn test_hover_color_member_includes_preview_with_alpha() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@class Color + ---@return Color + function Color(r, g, b, a) end + + SKIN = {} + SKIN.HeaderColor = Color(20, 40, 60, 128) + SKIN.HeaderColor + "#, + )?; + let file_id = ws.def(&content); + let value = extract_hover_markdown(&ws, file_id, position); + + verify_that!( + value, + contains_substring("(field) HeaderColor: Color(20, 40, 60, 128)") + )?; + verify_that!(value, contains_substring("data:image/svg+xml;base64,"))?; + verify_that!(value, contains_substring("`Color(20, 40, 60, 128)`"))?; + Ok(()) + } + fn extract_hover_markdown( ws: &ProviderVirtualWorkspace, file_id: glua_code_analysis::FileId, From 0c7be9e39a8b17a347444cc98df39d409a513fb8 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:21:13 +0100 Subject: [PATCH 7/8] feat: improve color preview metadata --- .../add_completions/add_decl_completion.rs | 9 ++- .../add_completions/add_member_completion.rs | 9 ++- .../glua_ls/src/handlers/hover/build_hover.rs | 2 + .../src/handlers/hover/color_swatch.rs | 57 ++++++++++++------- .../src/handlers/test/completion_test.rs | 47 +++++++++++++++ 5 files changed, 101 insertions(+), 23 deletions(-) diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs index 68ba8ce6..e1d3c467 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs @@ -29,6 +29,13 @@ pub fn add_decl_completion( let overload_count = count_function_overloads(builder.semantic_model.get_db(), typ); let (color, constructor_literal_detail) = get_decl_completion_literal_info(builder, decl_id, typ); + let completion_data_color = builder + .semantic_model + .get_emmyrc() + .document_color + .enable + .then(|| color.clone()) + .flatten(); let literal_detail = scalar_literal_detail(typ); let mut completion_item = CompletionItem { @@ -38,7 +45,7 @@ pub fn add_decl_completion( } else { get_completion_kind(typ) }), - data: match color.clone() { + data: match completion_data_color { Some(color) => CompletionData::from_property_owner_id_with_color( builder, decl_id.into(), diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs index 7eafa848..acf54542 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs @@ -115,10 +115,17 @@ pub fn add_member_completion_with_description_hint( get_member_completion_literal_info(builder, property_owner, &remove_nil_type); // 附加数据, 用于在`resolve`时进一步处理 + let completion_data_color = builder + .semantic_model + .get_emmyrc() + .document_color + .enable + .then(|| color.clone()) + .flatten(); let completion_data = if let Some(id) = &property_owner { if let Some(index) = member_info.overload_index { CompletionData::from_overload(builder, id.clone(), index, overload_count) - } else if let Some(color) = color.clone() { + } else if let Some(color) = completion_data_color { CompletionData::from_property_owner_id_with_color( builder, id.clone(), diff --git a/crates/glua_ls/src/handlers/hover/build_hover.rs b/crates/glua_ls/src/handlers/hover/build_hover.rs index c9856a7a..0740df0c 100644 --- a/crates/glua_ls/src/handlers/hover/build_hover.rs +++ b/crates/glua_ls/src/handlers/hover/build_hover.rs @@ -521,6 +521,7 @@ fn add_decl_color_preview( color.green, color.blue, color.alpha, + &color.gmod_display, )); Some(()) } @@ -551,6 +552,7 @@ fn add_member_color_preview( color.green, color.blue, color.alpha, + &color.gmod_display, )); Some(()) } diff --git a/crates/glua_ls/src/handlers/hover/color_swatch.rs b/crates/glua_ls/src/handlers/hover/color_swatch.rs index 9e7c19ea..914ad62e 100644 --- a/crates/glua_ls/src/handlers/hover/color_swatch.rs +++ b/crates/glua_ls/src/handlers/hover/color_swatch.rs @@ -7,29 +7,34 @@ const SWATCH_SIZE: u32 = 14; /// Builds the description-region markdown for a color swatch: an inline SVG -/// image followed by the `Color(r, g, b[, a])` text. -/// -/// `alpha` is included in the text only when it is not fully opaque. -pub(crate) fn color_swatch_markdown(red: u8, green: u8, blue: u8, alpha: u8) -> String { - let data_uri = color_swatch_data_uri(red, green, blue); - let text = if alpha == u8::MAX { - format!("Color({}, {}, {})", red, green, blue) - } else { - format!("Color({}, {}, {}, {})", red, green, blue, alpha) - }; - format!("![]({}) `{}`", data_uri, text) +/// image followed by the already-rendered `Color(...)` text. +pub(crate) fn color_swatch_markdown( + red: u8, + green: u8, + blue: u8, + alpha: u8, + display: &str, +) -> String { + let data_uri = color_swatch_data_uri(red, green, blue, alpha); + format!("![]({}) `{}`", data_uri, display) } -/// Builds a `data:image/svg+xml;base64,...` URI for a solid color square. -pub(crate) fn color_swatch_data_uri(red: u8, green: u8, blue: u8) -> String { +/// Builds a `data:image/svg+xml;base64,...` URI for a color square. +pub(crate) fn color_swatch_data_uri(red: u8, green: u8, blue: u8, alpha: u8) -> String { + let opacity = f32::from(alpha) / 255.0; let svg = format!( "\ -", +\ +\ +\ +\ +\ +", size = SWATCH_SIZE, r = red, g = green, b = blue, + opacity = opacity, ); format!( "data:image/svg+xml;base64,{}", @@ -103,24 +108,34 @@ mod tests { #[test] fn data_uri_decodes_to_svg_with_expected_fill() { - let uri = color_swatch_data_uri(255, 0, 0); + let uri = color_swatch_data_uri(255, 0, 0, 255); let b64 = uri.strip_prefix("data:image/svg+xml;base64,").unwrap(); let svg = String::from_utf8(base64_decode(b64)).unwrap(); assert!(svg.contains("fill=\"#FF0000\""), "svg was: {svg}"); + assert!(svg.contains("fill-opacity=\"1.000\""), "svg was: {svg}"); assert!(svg.contains(" Result<()> + { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = ws.get_emmyrc(); + emmyrc.document_color.enable = false; + ws.update_emmyrc(emmyrc); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@class Color + + ---@return Color + function Color(r, g, b, a) end + + color_white = Color(255, 255, 255, 255) + + color_w + "#, + )?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .into_iter() + .find(|item| item.label == "color_white") + .ok_or("missing color_white completion") + .or_fail()?; + + verify_eq!(item.kind, Some(CompletionItemKind::COLOR))?; + verify_that!( + item.data.as_ref().and_then(|data| data.get("color")), + none() + )?; + Ok(()) + } + #[gtest] fn test_scalar_constant_completion_includes_literal_detail() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); From 52d831afa310554075fa502cc604c9d0c3a4c582 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:21:51 +0100 Subject: [PATCH 8/8] feat: enrich completion item metadata --- crates/glua_ls/src/context/lsp_features.rs | 15 + .../add_completions/add_decl_completion.rs | 10 +- .../add_completions/add_member_completion.rs | 110 ++++- .../completion/add_completions/mod.rs | 173 +++++++- .../handlers/completion/completion_builder.rs | 7 + crates/glua_ls/src/handlers/completion/mod.rs | 25 +- .../providers/table_field_provider.rs | 3 +- .../src/handlers/test/completion_test.rs | 387 ++++++++++++++++-- 8 files changed, 653 insertions(+), 77 deletions(-) diff --git a/crates/glua_ls/src/context/lsp_features.rs b/crates/glua_ls/src/context/lsp_features.rs index 1b3519df..4430b933 100644 --- a/crates/glua_ls/src/context/lsp_features.rs +++ b/crates/glua_ls/src/context/lsp_features.rs @@ -40,6 +40,21 @@ impl LspFeatures { false } + pub fn supports_completion_item_deprecated_tags(&self) -> bool { + self.client_capabilities + .text_document + .as_ref() + .and_then(|text_document| text_document.completion.as_ref()) + .and_then(|completion| completion.completion_item.as_ref()) + .and_then(|completion_item| completion_item.tag_support.as_ref()) + .is_some_and(|tag_support| { + tag_support.value_set.is_empty() + || tag_support + .value_set + .contains(&lsp_types::CompletionItemTag::DEPRECATED) + }) + } + pub fn supports_workspace_diagnostic(&self) -> bool { self.supports_pull_diagnostic() } diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs index e1d3c467..d2a3e80e 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_decl_completion.rs @@ -14,7 +14,7 @@ use super::{ color_info_from_expr, color_info_from_type, gmod_constructor_literal_detail, is_color_type, is_gmod_literal_constructor_type, scalar_literal_description, scalar_literal_detail, }, - get_completion_kind, get_description, get_detail, is_deprecated, + get_completion_tags, get_decl_completion_kind, get_description, get_detail, is_deprecated, }; pub fn add_decl_completion( @@ -43,9 +43,9 @@ pub fn add_decl_completion( kind: Some(if color.is_some() { lsp_types::CompletionItemKind::COLOR } else { - get_completion_kind(typ) + get_decl_completion_kind(builder, decl_id, typ) }), - data: match completion_data_color { + data: match completion_data_color { Some(color) => CompletionData::from_property_owner_id_with_color( builder, decl_id.into(), @@ -69,8 +69,10 @@ pub fn add_decl_completion( }), ..Default::default() }; - if is_deprecated(builder, property_owner.clone()) { + let deprecated = is_deprecated(builder, property_owner.clone()); + if deprecated { completion_item.deprecated = Some(true); + completion_item.tags = get_completion_tags(builder, Some(true)); } if builder.support_snippets(typ) { diff --git a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs index acf54542..7a4febb1 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/add_member_completion.rs @@ -1,6 +1,7 @@ use glua_code_analysis::{ - DbIndex, LuaAliasCallKind, LuaMemberId, LuaMemberInfo, LuaMemberKey, LuaSemanticDeclId, - LuaType, SemanticModel, get_keyof_members, try_extract_signature_id_from_field, + DbIndex, LuaAliasCallKind, LuaMemberId, LuaMemberInfo, LuaMemberKey, LuaMemberOwner, + LuaSemanticDeclId, LuaType, SemanticModel, get_keyof_members, + try_extract_signature_id_from_field, }; use glua_parser::{ LuaAssignStat, LuaAstNode, LuaAstToken, LuaExpr, LuaFuncStat, LuaGeneralToken, LuaIndexExpr, @@ -21,7 +22,8 @@ use super::{ color_info_from_expr, color_info_from_type, gmod_constructor_literal_detail, is_color_type, is_gmod_literal_constructor_type, scalar_literal_description, scalar_literal_detail, }, - get_completion_kind, get_description, get_detail, is_deprecated, + get_completion_kind, get_completion_tags, get_description, get_detail, is_deprecated, + is_table_namespace_type, }; #[derive(Debug, Clone, Copy, PartialEq)] @@ -108,13 +110,11 @@ pub fn add_member_completion_with_description_hint( let typ = &member_info.typ; let remove_nil_type = get_function_remove_nil(builder.semantic_model.get_db(), typ).unwrap_or(typ.clone()); - if status == CompletionTriggerStatus::Colon && !remove_nil_type.is_function() { + if status == CompletionTriggerStatus::Colon && !is_completion_callable_type(&remove_nil_type) { return None; } let (color, constructor_literal_detail) = get_member_completion_literal_info(builder, property_owner, &remove_nil_type); - - // 附加数据, 用于在`resolve`时进一步处理 let completion_data_color = builder .semantic_model .get_emmyrc() @@ -122,10 +122,12 @@ pub fn add_member_completion_with_description_hint( .enable .then(|| color.clone()) .flatten(); + + // 附加数据, 用于在`resolve`时进一步处理 let completion_data = if let Some(id) = &property_owner { if let Some(index) = member_info.overload_index { CompletionData::from_overload(builder, id.clone(), index, overload_count) - } else if let Some(color) = completion_data_color { + } else if let Some(color) = completion_data_color { CompletionData::from_property_owner_id_with_color( builder, id.clone(), @@ -167,22 +169,30 @@ pub fn add_member_completion_with_description_hint( let deprecated = property_owner .as_ref() .map(|id| is_deprecated(builder, id.clone())); + let tags = get_completion_tags(builder, deprecated); + let kind = if color.is_some() { + lsp_types::CompletionItemKind::COLOR + } else if is_inferred_dynamic_member { + lsp_types::CompletionItemKind::VARIABLE + } else { + get_member_completion_kind( + builder.semantic_model.get_db(), + property_owner, + &remove_nil_type, + status, + ) + }; let mut completion_item = CompletionItem { label: label.clone(), - kind: Some(if color.is_some() { - lsp_types::CompletionItemKind::COLOR - } else if is_inferred_dynamic_member { - lsp_types::CompletionItemKind::VARIABLE - } else { - get_completion_kind(&remove_nil_type) - }), + kind: Some(kind), data: completion_data, label_details: Some(lsp_types::CompletionItemLabelDetails { detail, description, }), deprecated, + tags, ..Default::default() }; if status == CompletionTriggerStatus::Dot @@ -236,6 +246,7 @@ pub fn add_member_completion_with_description_hint( &remove_nil_type, call_display, deprecated, + Some(kind), label, overload_count, description_hint, @@ -325,6 +336,7 @@ fn add_signature_overloads( typ: &LuaType, call_display: CallDisplay, deprecated: Option, + kind: Option, label: String, overload_count: Option, description_hint: Option<&str>, @@ -357,13 +369,14 @@ fn add_signature_overloads( }; let completion_item = CompletionItem { label: label.clone(), - kind: Some(get_completion_kind(&typ)), + kind, data, label_details: Some(lsp_types::CompletionItemLabelDetails { detail, description, }), deprecated, + tags: get_completion_tags(builder, deprecated), ..Default::default() }; @@ -372,6 +385,73 @@ fn add_signature_overloads( Some(()) } +fn get_member_completion_kind( + db: &DbIndex, + property_owner: &Option, + typ: &LuaType, + status: CompletionTriggerStatus, +) -> lsp_types::CompletionItemKind { + let type_kind = get_completion_kind(typ); + if type_kind != lsp_types::CompletionItemKind::FUNCTION { + if is_global_table_namespace_member(db, property_owner, typ) { + return lsp_types::CompletionItemKind::INTERFACE; + } + + return match type_kind { + lsp_types::CompletionItemKind::CLASS + | lsp_types::CompletionItemKind::MODULE + | lsp_types::CompletionItemKind::STRUCT + | lsp_types::CompletionItemKind::TYPE_PARAMETER => type_kind, + _ => lsp_types::CompletionItemKind::FIELD, + }; + } + + if status == CompletionTriggerStatus::Colon || is_colon_defined_function(db, typ) { + lsp_types::CompletionItemKind::METHOD + } else { + type_kind + } +} + +fn is_completion_callable_type(typ: &LuaType) -> bool { + typ.is_function() || get_completion_kind(typ) == lsp_types::CompletionItemKind::FUNCTION +} + +fn is_global_table_namespace_member( + db: &DbIndex, + property_owner: &Option, + typ: &LuaType, +) -> bool { + if !is_table_namespace_type(typ) { + return false; + } + + let Some(LuaSemanticDeclId::Member(member_id)) = property_owner else { + return false; + }; + + let member_index = db.get_member_index(); + if let Some(LuaMemberOwner::GlobalPath(_)) = member_index.get_current_owner(member_id) { + return true; + } + + member_index + .get_member(member_id) + .and_then(|member| member.get_global_id()) + .is_some() +} + +fn is_colon_defined_function(db: &DbIndex, typ: &LuaType) -> bool { + match typ { + LuaType::Signature(signature_id) => db + .get_signature_index() + .get(signature_id) + .is_some_and(|signature| signature.is_colon_define), + LuaType::DocFunction(func) => func.is_colon_define(), + _ => false, + } +} + fn get_call_show( db: &DbIndex, typ: &LuaType, diff --git a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs index 59a90b8e..036577f5 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs @@ -13,8 +13,10 @@ pub(crate) use completion_item_info::{ color_info_from_expr, color_info_from_type, color_label_detail, color_preview_documentation, is_color_type, }; -use glua_code_analysis::{LuaSemanticDeclId, LuaType, RenderLevel}; -use lsp_types::CompletionItemKind; +use glua_code_analysis::{ + GlobalId, LuaDeclId, LuaMemberOwner, LuaSemanticDeclId, LuaType, LuaUnionType, RenderLevel, +}; +use lsp_types::{CompletionItemKind, CompletionItemTag}; use glua_code_analysis::humanize_type; @@ -38,21 +40,166 @@ pub fn check_visibility(builder: &mut CompletionBuilder, id: LuaSemanticDeclId) } pub fn get_completion_kind(typ: &LuaType) -> CompletionItemKind { - if typ.is_function() { - return CompletionItemKind::FUNCTION; - } else if is_completion_constant_type(typ) { - return CompletionItemKind::CONSTANT; - } else if typ.is_def() { - return CompletionItemKind::CLASS; - } else if typ.is_namespace() { - return CompletionItemKind::MODULE; + match typ { + LuaType::DocFunction(_) | LuaType::Function | LuaType::Signature(_) => { + CompletionItemKind::FUNCTION + } + LuaType::BooleanConst(_) + | LuaType::DocBooleanConst(_) + | LuaType::StringConst(_) + | LuaType::DocStringConst(_) + | LuaType::IntegerConst(_) + | LuaType::DocIntegerConst(_) + | LuaType::FloatConst(_) => CompletionItemKind::CONSTANT, + LuaType::Def(_) => CompletionItemKind::CLASS, + LuaType::Namespace(_) | LuaType::ModuleRef(_) => CompletionItemKind::MODULE, + LuaType::Table + | LuaType::TableConst(_) + | LuaType::Array(_) + | LuaType::Tuple(_) + | LuaType::Object(_) + | LuaType::TableGeneric(_) + | LuaType::TableOf(_) + | LuaType::Ref(_) + | LuaType::Instance(_) + | LuaType::Global => CompletionItemKind::STRUCT, + LuaType::Boolean + | LuaType::String + | LuaType::Integer + | LuaType::Number + | LuaType::Language(_) + | LuaType::Userdata + | LuaType::Thread + | LuaType::Io => CompletionItemKind::VALUE, + LuaType::Never => CompletionItemKind::UNIT, + LuaType::TplRef(_) | LuaType::StrTplRef(_) | LuaType::ConstTplRef(_) => { + CompletionItemKind::TYPE_PARAMETER + } + LuaType::Union(union) => get_union_completion_kind(union.as_ref()), + LuaType::Intersection(intersection) => get_intersection_completion_kind(intersection), + LuaType::TypeGuard(inner) => get_completion_kind(inner), + LuaType::Variadic(variadic) => variadic + .get_type(0) + .map(get_completion_kind) + .unwrap_or(CompletionItemKind::VARIABLE), + LuaType::MultiLineUnion(_) + | LuaType::Generic(_) + | LuaType::Call(_) + | LuaType::DocAttribute(_) + | LuaType::Conditional(_) + | LuaType::ConditionalInfer(_) + | LuaType::Mapped(_) + | LuaType::Any + | LuaType::Unknown + | LuaType::Nil + | LuaType::SelfInfer => CompletionItemKind::VARIABLE, + } +} + +pub fn get_decl_completion_kind( + builder: &CompletionBuilder, + decl_id: LuaDeclId, + typ: &LuaType, +) -> CompletionItemKind { + if is_global_table_namespace_decl(builder, decl_id, typ) { + CompletionItemKind::CLASS + } else { + get_completion_kind(typ) + } +} + +fn get_intersection_completion_kind( + intersection: &glua_code_analysis::LuaIntersectionType, +) -> CompletionItemKind { + let mut fallback = CompletionItemKind::VARIABLE; + for kind in intersection.get_types().iter().map(get_completion_kind) { + if kind == CompletionItemKind::FUNCTION { + return CompletionItemKind::FUNCTION; + } + if fallback == CompletionItemKind::VARIABLE && kind != CompletionItemKind::VARIABLE { + fallback = kind; + } + } + + fallback +} + +pub fn is_table_namespace_type(typ: &LuaType) -> bool { + match typ { + LuaType::Table + | LuaType::TableConst(_) + | LuaType::TableGeneric(_) + | LuaType::TableOf(_) + | LuaType::Object(_) + | LuaType::Global => true, + LuaType::Union(union) => match union.as_ref() { + LuaUnionType::Nullable(typ) => is_table_namespace_type(typ), + LuaUnionType::Multi(types) => { + let mut non_nil_types = types.iter().filter(|typ| !matches!(typ, LuaType::Nil)); + non_nil_types.next().is_some_and(is_table_namespace_type) + && non_nil_types.all(is_table_namespace_type) + } + }, + LuaType::TypeGuard(inner) => is_table_namespace_type(inner), + _ => false, + } +} + +fn is_global_table_namespace_decl( + builder: &CompletionBuilder, + decl_id: LuaDeclId, + typ: &LuaType, +) -> bool { + if !is_table_namespace_type(typ) { + return false; } - CompletionItemKind::VARIABLE + let db = builder.semantic_model.get_db(); + let Some(decl) = db.get_decl_index().get_decl(&decl_id) else { + return false; + }; + + decl.is_global() + && db + .get_member_index() + .get_member_len(&LuaMemberOwner::GlobalPath(GlobalId::new(decl.get_name()))) + > 0 +} + +pub fn get_completion_tags( + builder: &CompletionBuilder, + deprecated: Option, +) -> Option> { + (deprecated.unwrap_or(false) && builder.supports_deprecated_completion_tags()) + .then_some(vec![CompletionItemTag::DEPRECATED]) } -fn is_completion_constant_type(typ: &LuaType) -> bool { - typ.is_const() || matches!(typ, LuaType::DocBooleanConst(_)) +fn get_union_completion_kind(union: &LuaUnionType) -> CompletionItemKind { + let kinds = match union { + LuaUnionType::Nullable(typ) => return get_completion_kind(typ), + LuaUnionType::Multi(types) => types + .iter() + .filter(|typ| !matches!(typ, LuaType::Nil)) + .map(get_completion_kind) + .collect::>(), + }; + + let Some(first) = kinds.first().copied() else { + return CompletionItemKind::UNIT; + }; + + if kinds.iter().all(|kind| *kind == first) { + first + } else if kinds.iter().all(|kind| { + matches!( + *kind, + CompletionItemKind::CONSTANT | CompletionItemKind::VALUE + ) + }) { + CompletionItemKind::VALUE + } else { + CompletionItemKind::VARIABLE + } } pub fn is_deprecated(builder: &CompletionBuilder, id: LuaSemanticDeclId) -> bool { diff --git a/crates/glua_ls/src/handlers/completion/completion_builder.rs b/crates/glua_ls/src/handlers/completion/completion_builder.rs index 6082f46c..709aef6f 100644 --- a/crates/glua_ls/src/handlers/completion/completion_builder.rs +++ b/crates/glua_ls/src/handlers/completion/completion_builder.rs @@ -17,6 +17,7 @@ pub struct CompletionBuilder<'a> { /// 是否为空格字符触发的补全(非主动触发) pub is_space_trigger_character: bool, pub position_offset: TextSize, + supports_deprecated_completion_tags: bool, } impl<'a> CompletionBuilder<'a> { @@ -26,6 +27,7 @@ impl<'a> CompletionBuilder<'a> { cancel_token: CancellationToken, trigger_kind: CompletionTriggerKind, position_offset: TextSize, + supports_deprecated_completion_tags: bool, ) -> Self { let is_space_trigger_character = if trigger_kind == CompletionTriggerKind::TRIGGER_CHARACTER { @@ -44,6 +46,7 @@ impl<'a> CompletionBuilder<'a> { trigger_kind, is_space_trigger_character, position_offset, + supports_deprecated_completion_tags, } } @@ -86,4 +89,8 @@ impl<'a> CompletionBuilder<'a> { .completion .call_snippet } + + pub fn supports_deprecated_completion_tags(&self) -> bool { + self.supports_deprecated_completion_tags + } } diff --git a/crates/glua_ls/src/handlers/completion/mod.rs b/crates/glua_ls/src/handlers/completion/mod.rs index 5f2eb565..21b0d830 100644 --- a/crates/glua_ls/src/handlers/completion/mod.rs +++ b/crates/glua_ls/src/handlers/completion/mod.rs @@ -70,7 +70,7 @@ pub async fn on_completion_handler( return None; } - let result = completion( + let result = completion_with_deprecated_tag_support( &analysis, file_id, position, @@ -79,17 +79,39 @@ pub async fn on_completion_handler( .map(|context| context.trigger_kind) .unwrap_or(CompletionTriggerKind::INVOKED), cancel_token, + context + .lsp_features() + .supports_completion_item_deprecated_tags(), ); result } +#[cfg(test)] pub fn completion( analysis: &EmmyLuaAnalysis, file_id: FileId, position: Position, trigger_kind: CompletionTriggerKind, cancel_token: CancellationToken, +) -> Option { + completion_with_deprecated_tag_support( + analysis, + file_id, + position, + trigger_kind, + cancel_token, + true, + ) +} + +pub fn completion_with_deprecated_tag_support( + analysis: &EmmyLuaAnalysis, + file_id: FileId, + position: Position, + trigger_kind: CompletionTriggerKind, + cancel_token: CancellationToken, + supports_deprecated_completion_tags: bool, ) -> Option { let semantic_model = analysis.compilation.get_semantic_model(file_id)?; if !semantic_model.get_emmyrc().completion.enable { @@ -120,6 +142,7 @@ pub fn completion( cancel_token.clone(), trigger_kind, position_offset, + supports_deprecated_completion_tags, ); add_completions(&mut builder); if cancel_token.is_cancelled() { diff --git a/crates/glua_ls/src/handlers/completion/providers/table_field_provider.rs b/crates/glua_ls/src/handlers/completion/providers/table_field_provider.rs index dc11327f..30f04590 100644 --- a/crates/glua_ls/src/handlers/completion/providers/table_field_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/table_field_provider.rs @@ -9,7 +9,7 @@ use lsp_types::{CompletionItem, InsertTextFormat, InsertTextMode}; use rowan::NodeOrToken; use crate::handlers::completion::{ - add_completions::{check_visibility, is_deprecated}, + add_completions::{check_visibility, get_completion_tags, is_deprecated}, completion_builder::CompletionBuilder, completion_data::CompletionData, providers::function_provider::dispatch_type, @@ -150,6 +150,7 @@ fn add_field_key_completion( kind: Some(lsp_types::CompletionItemKind::PROPERTY), data, deprecated, + tags: get_completion_tags(builder, deprecated), insert_text: Some(insert_text), insert_text_format, ..Default::default() diff --git a/crates/glua_ls/src/handlers/test/completion_test.rs b/crates/glua_ls/src/handlers/test/completion_test.rs index 810e2157..f7cf5716 100644 --- a/crates/glua_ls/src/handlers/test/completion_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_test.rs @@ -5,8 +5,8 @@ mod tests { }; use googletest::prelude::*; use lsp_types::{ - CompletionItemKind, CompletionResponse, CompletionTriggerKind, Documentation, - InsertTextFormat, MarkupContent, + CompletionItemKind, CompletionItemTag, CompletionResponse, CompletionTriggerKind, + Documentation, InsertTextFormat, MarkupContent, }; use std::{ fs, @@ -15,7 +15,7 @@ mod tests { }; use tokio_util::sync::CancellationToken; - use crate::handlers::completion::completion; + use crate::handlers::completion::{completion, completion_with_deprecated_tag_support}; use crate::handlers::test_lib::{ProviderVirtualWorkspace, VirtualCompletionItem, check}; #[gtest] @@ -671,7 +671,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "box".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::STRUCT, ..Default::default() }], CompletionTriggerKind::TRIGGER_CHARACTER, @@ -1112,7 +1112,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "on_add".to_string(), - kind: CompletionItemKind::FUNCTION, + kind: CompletionItemKind::METHOD, label_detail: Some("(a, b)".to_string()), }], CompletionTriggerKind::TRIGGER_CHARACTER, @@ -1171,7 +1171,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "on_add".to_string(), - kind: CompletionItemKind::FUNCTION, + kind: CompletionItemKind::METHOD, label_detail: Some("(owner)".to_string()), }], CompletionTriggerKind::TRIGGER_CHARACTER, @@ -1407,7 +1407,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "nameX".to_string(), - kind: CompletionItemKind::CONSTANT, + kind: CompletionItemKind::FIELD, label_detail: Some(" = 1".to_string()), }], )); @@ -1437,12 +1437,12 @@ mod tests { vec![ VirtualCompletionItem { label: "optional_num".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, ..Default::default() }, VirtualCompletionItem { label: "set".to_string(), - kind: CompletionItemKind::FUNCTION, + kind: CompletionItemKind::METHOD, label_detail: Some("(self) -> nil".to_string()), }, ], @@ -1518,7 +1518,7 @@ mod tests { vec![ VirtualCompletionItem { label: "_abc".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, VirtualCompletionItem { @@ -1665,7 +1665,7 @@ mod tests { }, VirtualCompletionItem { label: "foo".to_string(), - kind: CompletionItemKind::CONSTANT, + kind: CompletionItemKind::FIELD, label_detail: Some(" = 0".to_string()), }, VirtualCompletionItem { @@ -1753,7 +1753,7 @@ mod tests { }, VirtualCompletionItem { label: "x".to_string(), - kind: CompletionItemKind::CONSTANT, + kind: CompletionItemKind::FIELD, label_detail: Some(" = 1".to_string()), }, ], @@ -1769,7 +1769,7 @@ mod tests { }, VirtualCompletionItem { label: "foo".to_string(), - kind: CompletionItemKind::CONSTANT, + kind: CompletionItemKind::FIELD, label_detail: Some(" = 1".to_string()), }, ], @@ -1780,12 +1780,12 @@ mod tests { vec![ VirtualCompletionItem { label: "[1]".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, VirtualCompletionItem { label: "x".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, ], @@ -1828,12 +1828,12 @@ mod tests { }, VirtualCompletionItem { label: "x".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, VirtualCompletionItem { label: "y".to_string(), - kind: CompletionItemKind::CONSTANT, + kind: CompletionItemKind::FIELD, label_detail: Some(" = 0".to_string()), }, ], @@ -1862,7 +1862,7 @@ mod tests { }, VirtualCompletionItem { label: "x".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, VirtualCompletionItem { @@ -1896,7 +1896,7 @@ mod tests { }, VirtualCompletionItem { label: "x".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, VirtualCompletionItem { @@ -1930,12 +1930,12 @@ mod tests { }, VirtualCompletionItem { label: "x".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, VirtualCompletionItem { label: "y".to_string(), - kind: CompletionItemKind::FUNCTION, + kind: CompletionItemKind::METHOD, label_detail: Some("(self)".to_string()), }, ], @@ -1964,12 +1964,12 @@ mod tests { }, VirtualCompletionItem { label: "x".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, VirtualCompletionItem { label: "y".to_string(), - kind: CompletionItemKind::CONSTANT, + kind: CompletionItemKind::FIELD, label_detail: Some(" = 0".to_string()), }, ], @@ -1998,7 +1998,7 @@ mod tests { }, VirtualCompletionItem { label: "x".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, VirtualCompletionItem { @@ -2034,17 +2034,17 @@ mod tests { }, VirtualCompletionItem { label: "x".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, label_detail: None, }, VirtualCompletionItem { label: "y".to_string(), - kind: CompletionItemKind::CONSTANT, + kind: CompletionItemKind::FIELD, label_detail: Some(" = 0".to_string()), }, VirtualCompletionItem { label: "init".to_string(), - kind: CompletionItemKind::FUNCTION, + kind: CompletionItemKind::METHOD, label_detail: Some("(self) -> nil".to_string()), }, ], @@ -2329,7 +2329,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "a".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, ..Default::default() },], )); @@ -2464,7 +2464,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "negate".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, ..Default::default() },], )); @@ -2487,7 +2487,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "a".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, ..Default::default() },], )); @@ -2547,7 +2547,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "\"age\"".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::FIELD, ..Default::default() },], CompletionTriggerKind::TRIGGER_CHARACTER @@ -2575,12 +2575,12 @@ mod tests { vec![ VirtualCompletionItem { label: "\"bar\"".to_string(), - kind: CompletionItemKind::CONSTANT, + kind: CompletionItemKind::FIELD, label_detail: Some(" = 2".to_string()), }, VirtualCompletionItem { label: "\"foo\"".to_string(), - kind: CompletionItemKind::CONSTANT, + kind: CompletionItemKind::FIELD, label_detail: Some(" = 1".to_string()), }, ], @@ -2615,7 +2615,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "toBe".to_string(), - kind: CompletionItemKind::FUNCTION, + kind: CompletionItemKind::METHOD, label_detail: Some("()".to_string()), },], CompletionTriggerKind::TRIGGER_CHARACTER @@ -3683,7 +3683,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "ClientOnlyMethod".to_string(), - kind: CompletionItemKind::FUNCTION, + kind: CompletionItemKind::METHOD, label_detail: Some("()".to_string()), }], )); @@ -3716,7 +3716,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "AnnotatedClientMethod".to_string(), - kind: CompletionItemKind::FUNCTION, + kind: CompletionItemKind::METHOD, label_detail: Some("()".to_string()), }], )); @@ -4021,7 +4021,7 @@ mod tests { .ok_or("missing ZzzPluginHook completion") .or_fail()?; - verify_eq!(item.kind, Some(CompletionItemKind::FUNCTION))?; + verify_eq!(item.kind, Some(CompletionItemKind::METHOD))?; let item_detail = item .label_details .as_ref() @@ -4159,7 +4159,308 @@ mod tests { )?; Ok(()) } - + + #[gtest] + fn test_deprecated_completion_uses_completion_item_tag() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@deprecated use new_api + function old_api() end + + old + "#, + )?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .into_iter() + .find(|item| item.label == "old_api") + .ok_or("missing old_api completion") + .or_fail()?; + + verify_eq!(item.deprecated, Some(true))?; + verify_eq!(item.tags, Some(vec![CompletionItemTag::DEPRECATED]))?; + + let result = completion_with_deprecated_tag_support( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + false, + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let item = items + .into_iter() + .find(|item| item.label == "old_api") + .ok_or("missing old_api completion") + .or_fail()?; + + verify_eq!(item.deprecated, Some(true))?; + verify_eq!(item.tags, None)?; + Ok(()) + } + + #[gtest] + fn test_member_completion_uses_method_and_property_kinds() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@class Panel + local PANEL = {} + PANEL.Title = "Scoreboard" + function PANEL:Paint(w, h) end + + PANEL. + "#, + )?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + + let paint = items + .iter() + .find(|item| item.label == "Paint") + .ok_or("missing Paint completion") + .or_fail()?; + verify_eq!(paint.kind, Some(CompletionItemKind::METHOD))?; + verify_that!( + paint + .label_details + .as_ref() + .and_then(|details| details.detail.as_ref()), + some(eq("(self, w, h)")) + )?; + + let title = items + .iter() + .find(|item| item.label == "Title") + .ok_or("missing Title completion") + .or_fail()?; + verify_eq!(title.kind, Some(CompletionItemKind::FIELD))?; + + Ok(()) + } + + #[gtest] + fn test_callable_union_member_completion_keeps_callable_kinds() -> Result<()> { + fn completion_kind(source: &str, label: &str) -> Result> { + let mut ws = ProviderVirtualWorkspace::new(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content(source)?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + + Ok(items + .into_iter() + .find(|item| item.label == label) + .and_then(|item| item.kind)) + } + + verify_eq!( + completion_kind( + r#" + ---@class Panel + ---@field Callback fun(self: Panel)|fun(self: Panel, value: number) + ---@type Panel + local panel + + panel. + "#, + "Callback" + )?, + Some(CompletionItemKind::FUNCTION) + )?; + + verify_eq!( + completion_kind( + r#" + ---@class Panel + ---@field Callback fun(self: Panel)|fun(self: Panel, value: number) + ---@type Panel + local panel + + panel: + "#, + "Callback" + )?, + Some(CompletionItemKind::METHOD) + )?; + + verify_eq!( + completion_kind( + r#" + ---@class Panel + ---@field Callback { value: number } & fun(self: Panel) + ---@type Panel + local panel + + panel: + "#, + "Callback" + )?, + Some(CompletionItemKind::METHOD) + )?; + + Ok(()) + } + + #[gtest] + fn test_completion_kinds_use_richer_type_mapping() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + + check!(ws.check_completion( + r#" + local scoreboard = {} + scoreb + "#, + vec![VirtualCompletionItem { + label: "scoreboard".to_string(), + kind: CompletionItemKind::STRUCT, + ..Default::default() + }], + )); + + check!(ws.check_completion( + r#" + ---@type number + local player_count + player_c + "#, + vec![VirtualCompletionItem { + label: "player_count".to_string(), + kind: CompletionItemKind::VALUE, + ..Default::default() + }], + )); + + check!(ws.check_completion( + r#" + ---@type never + local no_value + no_v + "#, + vec![VirtualCompletionItem { + label: "no_value".to_string(), + kind: CompletionItemKind::UNIT, + ..Default::default() + }], + )); + + Ok(()) + } + + #[gtest] + fn test_global_table_namespace_completion_kinds() -> Result<()> { + fn completion_kind(source: &str, label: &str) -> Result> { + let mut ws = ProviderVirtualWorkspace::new(); + let (content, position) = ProviderVirtualWorkspace::handle_file_content(source)?; + let file_id = ws.def(&content); + let result = completion( + &ws.analysis, + file_id, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + + Ok(items + .into_iter() + .find(|item| item.label == label) + .and_then(|item| item.kind)) + } + + verify_eq!( + completion_kind( + r#" + ix = ix or {} + ix.character = ix.character or {} + function ix.character.get(id) end + + i + "#, + "ix" + )?, + Some(CompletionItemKind::CLASS) + )?; + + verify_eq!( + completion_kind( + r#" + ix = ix or {} + ix.character = ix.character or {} + function ix.character.get(id) end + + ix. + "#, + "character" + )?, + Some(CompletionItemKind::INTERFACE) + )?; + + verify_eq!( + completion_kind( + r#" + ix = ix or {} + ix.character = ix.character or {} + function ix.character.get(id) end + + ix.character. + "#, + "get" + )?, + Some(CompletionItemKind::FUNCTION) + )?; + + Ok(()) + } + #[gtest] fn test_scalar_constant_completion_includes_literal_detail() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -4364,7 +4665,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "SPAWN_POS".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::STRUCT, label_detail: Some(" = Vector(-200, 0, 50)".to_string()), }], )); @@ -4380,7 +4681,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "CAMERA_ANGLE".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::STRUCT, label_detail: Some(" = Angle(10.5, 180, 0)".to_string()), }], )); @@ -4411,12 +4712,12 @@ mod tests { vec![ VirtualCompletionItem { label: "CameraOffset".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::STRUCT, label_detail: Some(" = Vector(-200, 0, 50)".to_string()), }, VirtualCompletionItem { label: "SpawnAngle".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::STRUCT, label_detail: Some(" = Angle(0, 180, 0)".to_string()), }, ], @@ -4441,7 +4742,7 @@ mod tests { "#, vec![VirtualCompletionItem { label: "DYNAMIC_POS".to_string(), - kind: CompletionItemKind::VARIABLE, + kind: CompletionItemKind::STRUCT, label_detail: None, }], ));