diff --git a/crates/glua_code_analysis/resources/schema.json b/crates/glua_code_analysis/resources/schema.json index 58be5552..5945927d 100644 --- a/crates/glua_code_analysis/resources/schema.json +++ b/crates/glua_code_analysis/resources/schema.json @@ -2094,17 +2094,6 @@ }, "EmmyrcHover": { "properties": { - "customDetail": { - "default": null, - "description": "The detail number of hover information.\nDefault is `None`, which means using the default detail level.\nYou can set it to a number between `1` and `255` to customize", - "format": "uint8", - "maximum": 255, - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, "enable": { "default": true, "description": "Enable showing documentation on hover.", @@ -2912,7 +2901,6 @@ "hover": { "$ref": "#/$defs/EmmyrcHover", "default": { - "customDetail": null, "enable": true } }, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/decl/exprs.rs b/crates/glua_code_analysis/src/compilation/analyzer/decl/exprs.rs index c92b29c6..99544973 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/decl/exprs.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/decl/exprs.rs @@ -225,7 +225,14 @@ fn analyze_closure_params( } pub fn analyze_table_expr(analyzer: &mut DeclAnalyzer, table_expr: LuaTableExpr) -> Option<()> { - if table_expr.is_object() { + // Register members for keyed/object tables AND for shaped sequential + // literals (arrays whose rows are themselves table literals). The latter are + // inferred as TableConst (see `infer_table_expr`), so their integer-keyed + // members (`[1]`, `[2]`, ...) must be registered here for `t[1]` lookups and + // rich hover. Simple scalar arrays are summarized as `T[]` and need no + // members. The predicate is purely syntactic so this declaration pass and + // the inference pass agree on which literals are materialized. + if table_expr.is_object() || table_expr.is_shaped_array_literal() { let file_id = analyzer.get_file_id(); let owner_id = LuaMemberOwner::Element(InFiled { file_id, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs index 77fdd4e1..60d4910c 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs @@ -2302,8 +2302,9 @@ fn resolve_local_registration_region( register_position: TextSize, ) -> Option<(LuaDeclId, TextSize)> { let decl_id = resolve_local_decl_id_at_position(db, file_id, var_name, register_position)?; - let region_start = find_latest_decl_write_before_position(db, file_id, decl_id, register_position) - .unwrap_or(decl_id.position); + let region_start = + find_latest_decl_write_before_position(db, file_id, decl_id, register_position) + .unwrap_or(decl_id.position); Some((decl_id, region_start)) } @@ -3437,9 +3438,9 @@ fn find_registered_table_expr( if let Some(assign_stat) = LuaAssignStat::cast(ancestor.clone()) { let (vars, exprs) = assign_stat.get_var_and_expr_list(); - let var_index = vars.iter().position(|var| { - var.syntax().text_range().start() == write_position - })?; + let var_index = vars + .iter() + .position(|var| var.syntax().text_range().start() == write_position)?; return value_expr_as_table(exprs.get(var_index)?); } } @@ -3566,10 +3567,8 @@ fn synthesize_panel_class( // literal. Binding the decl slot is safe here because the local is // never reused, so there is no region to collapse. Reassigned // locals are deliberately left untouched to avoid collapse. - db.get_type_index_mut().force_bind_type( - decl_id.into(), - LuaTypeCache::InferType(class_type.clone()), - ); + db.get_type_index_mut() + .force_bind_type(decl_id.into(), LuaTypeCache::InferType(class_type.clone())); } // Transfer the members defined in this registration's table region to @@ -3594,12 +3593,8 @@ fn synthesize_panel_class( // by source position `[latest_write_position, register_position)`. // This stays correct if a future flow-aware collector starts keying // members under the per-region literal instead. - let member_source_ranges = collect_panel_member_source_ranges( - db, - file_id, - decl_id, - &table_range, - ); + let member_source_ranges = + collect_panel_member_source_ranges(db, file_id, decl_id, &table_range); let mut table_member_ids = HashSet::new(); for (source_idx, source_range) in member_source_ranges.iter().enumerate() { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 9ac06392..6018eccf 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use crate::{ CacheEntry, FileId, GmodRealm, InFiled, InferFailReason, LuaArrayType, LuaMemberKey, LuaSemanticDeclId, LuaSignatureId, LuaTypeCache, LuaTypeOwner, TypeOps, @@ -440,6 +442,9 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt match analyzer.infer_expr(&prefix_expr.clone()) { Ok(prefix_type) => { + if should_skip_ambiguous_unknown_key_table_owner(analyzer, &prefix_type, &index_expr) { + return Some(()); + } let (member_owner, set_owner_only) = resolve_index_expr_member_owner_for_file(&prefix_type, Some(analyzer.file_id))?; apply_index_expr_member_owner(analyzer, index_expr, member_owner, set_owner_only); @@ -463,6 +468,99 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt Some(()) } +fn should_skip_ambiguous_unknown_key_table_owner( + analyzer: &mut LuaAnalyzer, + prefix_type: &LuaType, + index_expr: &LuaIndexExpr, +) -> bool { + let Some(index_key) = index_expr.get_index_key() else { + return false; + }; + let cache = analyzer + .context + .infer_manager + .get_infer_cache(analyzer.file_id); + let Ok(member_key) = LuaMemberKey::from_index_key_or_unknown(analyzer.db, cache, &index_key) + else { + return false; + }; + if !matches!(member_key, LuaMemberKey::ExprType(ref typ) if typ.is_unknown()) { + return false; + } + + has_multiple_distinct_index_expr_member_owners(prefix_type) +} + +fn has_multiple_distinct_index_expr_member_owners(typ: &LuaType) -> bool { + let mut owners = HashSet::new(); + collect_distinct_index_expr_member_owners(typ, &mut owners); + owners.len() > 1 +} + +fn collect_distinct_index_expr_member_owners( + typ: &LuaType, + owners: &mut HashSet, +) -> bool { + match typ { + LuaType::TableConst(in_file_range) => { + insert_index_expr_member_owner(owners, LuaMemberOwner::Element(in_file_range.clone())) + } + LuaType::Def(def_id) => { + insert_index_expr_member_owner(owners, LuaMemberOwner::Type(def_id.clone())) + } + LuaType::Ref(ref_id) => { + insert_index_expr_member_owner(owners, LuaMemberOwner::Type(ref_id.clone())) + } + LuaType::Instance(instance) => insert_index_expr_member_owner( + owners, + LuaMemberOwner::Element(instance.get_range().clone()), + ), + LuaType::TableOf(inner) => collect_distinct_index_expr_member_owners(inner, owners), + LuaType::TypeGuard(inner) => collect_distinct_index_expr_member_owners(inner, owners), + LuaType::Union(union) => { + for typ in union.into_vec() { + if collect_distinct_index_expr_member_owners(&typ, owners) { + return true; + } + } + false + } + LuaType::Intersection(intersection) => { + for typ in intersection.get_types() { + if collect_distinct_index_expr_member_owners(typ, owners) { + return true; + } + } + false + } + LuaType::MergedTable(merged_table) => { + for typ in merged_table.get_types() { + if collect_distinct_index_expr_member_owners(typ, owners) { + return true; + } + } + false + } + LuaType::MultiLineUnion(union) => { + for (typ, _) in union.get_unions() { + if collect_distinct_index_expr_member_owners(typ, owners) { + return true; + } + } + false + } + _ => false, + } +} + +fn insert_index_expr_member_owner( + owners: &mut HashSet, + owner: LuaMemberOwner, +) -> bool { + owners.insert(owner); + owners.len() > 1 +} + fn try_resolve_scoped_class_prefix_member_owner( analyzer: &LuaAnalyzer, prefix_expr: &LuaExpr, @@ -950,6 +1048,10 @@ fn is_table_shape_cleanup_type(typ: &LuaType) -> bool { .get_types() .iter() .all(is_table_shape_cleanup_type), + LuaType::MergedTable(merged_table) => merged_table + .get_types() + .iter() + .all(is_table_shape_cleanup_type), LuaType::MultiLineUnion(union) => { let types = union.get_unions(); !types.is_empty() @@ -2038,10 +2140,22 @@ fn register_expr_key_member(analyzer: &mut LuaAnalyzer, field: &LuaTableField) { .add_member(owner_id, member); } +/// Whether this value-field (positional `{ expr }`) belongs to a shaped +/// sequential table literal whose integer members were registered in the +/// declaration pass (see `analyze_table_expr`). Such members need their value +/// types inferred and bound here, exactly like keyed/assign fields, otherwise +/// the registered `[n]` member has no type cache and dynamic indexing degrades. +fn is_shaped_array_value_field(field: &LuaTableField) -> bool { + field.is_value_field() + && field + .get_parent::() + .is_some_and(|table_expr| table_expr.is_shaped_array_literal()) +} + pub fn analyze_table_field(analyzer: &mut LuaAnalyzer, field: LuaTableField) -> Option<()> { register_expr_key_member(analyzer, &field); - if field.is_assign_field() { + if field.is_assign_field() || is_shaped_array_value_field(&field) { let value_expr = field.get_value_expr()?; let member_id = LuaMemberId::new(field.get_syntax_id(), analyzer.file_id); let value_type = match analyzer.infer_expr(&value_expr.clone()) { @@ -2243,3 +2357,36 @@ fn is_undefined_global_name_expr(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool }; !has_global } + +#[cfg(test)] +mod tests { + use rowan::{TextRange, TextSize}; + + use crate::{FileId, InFiled, LuaMergedTableType, LuaUnionType}; + + use super::*; + + fn table_const(start: u32, end: u32) -> LuaType { + LuaType::TableConst(InFiled::new( + FileId::new(0), + TextRange::new(TextSize::new(start), TextSize::new(end)), + )) + } + + #[test] + fn duplicate_table_owner_is_not_ambiguous() { + let table = table_const(1, 2); + let typ = LuaMergedTableType::new(vec![table.clone(), table]).into(); + + assert!(!has_multiple_distinct_index_expr_member_owners(&typ)); + } + + #[test] + fn distinct_table_owners_are_ambiguous() { + let typ = LuaType::Union( + LuaUnionType::from_vec(vec![table_const(1, 2), table_const(3, 4)]).into(), + ); + + assert!(has_multiple_distinct_index_expr_member_owners(&typ)); + } +} diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs index 98a1766a..0fbc6597 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs @@ -26,8 +26,7 @@ use crate::{ find_members_with_key, humanize_type, semantic::{ InferGuard, LuaInferCache, SelfRefId, SemanticDeclGuard, VarRefId, VarRefRootId, - get_var_expr_var_ref_id, - infer_call_expr_func, infer_expr, infer_expr_semantic_decl, + get_var_expr_var_ref_id, infer_call_expr_func, infer_expr, infer_expr_semantic_decl, }, }; use smol_str::SmolStr; @@ -1787,18 +1786,14 @@ fn extend_var_ref_id_with_path( }; let arc_path = ArcIntern::from(SmolStr::new(&full_path)); match var_ref_id { - VarRefId::VarRef(decl_id) => Some(VarRefId::IndexRef( - VarRefRootId::Decl(decl_id), - arc_path, - )), + VarRefId::VarRef(decl_id) => { + Some(VarRefId::IndexRef(VarRefRootId::Decl(decl_id), arc_path)) + } VarRefId::SelfRef(self_ref_id) => Some(VarRefId::IndexRef( VarRefRootId::SelfRef(self_ref_id), arc_path, )), - VarRefId::IndexRef(root, _) => Some(VarRefId::IndexRef( - root, - arc_path, - )), + VarRefId::IndexRef(root, _) => Some(VarRefId::IndexRef(root, arc_path)), VarRefId::GlobalName(_, _) => None, } } diff --git a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs index c150a405..524e1f75 100644 --- a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs @@ -1860,7 +1860,9 @@ mod test { } let info = semantic_model.get_semantic_info(token.syntax().clone().into())?; match &info.typ { - LuaType::Def(id) => Some((name_expr.get_position(), id.get_simple_name().to_string())), + LuaType::Def(id) => { + Some((name_expr.get_position(), id.get_simple_name().to_string())) + } _ => None, } }) @@ -4424,10 +4426,15 @@ mod test { eq("tableof"), "the local should keep the GetTable(self) tableof type after `entTbl = entTbl or getTable(self)`" ); + // The shaped sequential literal `{ { 1.0 } }` is modeled as a dynamic + // table (TableConst) with an integer member `[1]` holding the inner row, + // rather than the old immutable nested-tuple `((1.0))`. The detailed + // rendering preserves that rich shape instead of degrading to a generic + // `table`, which is the property this test guards. assert_that!( - ws.humanize_type(input_floats_type).as_str(), - eq("((1.0))"), - "entTbl.inputFloats should keep the scripted-class field type instead of degrading to generic table access" + ws.humanize_type_detailed(input_floats_type).as_str(), + eq("{\n [1]: (1.0),\n}"), + "entTbl.inputFloats should keep the scripted-class field shape instead of degrading to generic table access" ); let diagnostics = ws @@ -4582,6 +4589,7 @@ mod test { emmyrc.gmod.infer_dynamic_fields = true; ws.update_emmyrc(emmyrc); ws.enable_check(DiagnosticCode::UndefinedField); + ws.enable_check(DiagnosticCode::UncheckedNilAccess); ws.def_file( "lua/entities/base_glide/shared.lua", @@ -4631,19 +4639,37 @@ mod test { "#, ); - let field_names: Vec = ws + let diagnostics = ws .analysis .diagnose_file(file_id, CancellationToken::new()) - .unwrap_or_default() - .into_iter() + .unwrap_or_default(); + + let field_names: Vec = diagnostics + .iter() .filter(|d| d.code == Some(NumberOrString::String("undefined-field".to_string()))) - .map(|d| d.message) + .map(|d| d.message.clone()) .collect(); assert!( !field_names.iter().any(|m| m.contains("`[i]`")), "numeric for index into inferred ENT weapons table should not trigger undefined-field: {field_names:?}" ); + + let unchecked_nil_accesses: Vec = diagnostics + .iter() + .filter(|d| { + d.code + == Some(NumberOrString::String( + DiagnosticCode::UncheckedNilAccess.get_name().to_string(), + )) + }) + .map(|d| d.message.clone()) + .collect(); + + assert!( + unchecked_nil_accesses.is_empty(), + "numeric for index into inferred ENT weapons table should not trigger unchecked-nil-access: {unchecked_nil_accesses:?}" + ); } #[gtest] @@ -7505,7 +7531,10 @@ mod test { assert!( y_display.contains("number") || y_display.contains("integer") - || matches!(y_type, LuaType::IntegerConst(_) | LuaType::Number | LuaType::Integer), + || matches!( + y_type, + LuaType::IntegerConst(_) | LuaType::Number | LuaType::Integer + ), "PanelB region: self.value should resolve to number/integer, got {y_display:?} ({y_type:?})" ); diff --git a/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs b/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs index 981828b6..acc06d60 100644 --- a/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs @@ -1,6 +1,8 @@ #[cfg(test)] mod test { - use glua_parser::{LuaAst, LuaAstNode, LuaAstToken, LuaIndexKey, LuaLocalName, LuaVarExpr}; + use glua_parser::{ + LuaAst, LuaAstNode, LuaAstToken, LuaExpr, LuaIndexKey, LuaLocalName, LuaVarExpr, + }; use googletest::prelude::*; use lsp_types::{NumberOrString, Uri}; use smol_str::SmolStr; @@ -27,6 +29,26 @@ mod test { diagnostics.iter().any(|diagnostic| diagnostic.code == code) } + fn file_diagnostic_messages( + ws: &mut VirtualWorkspace, + file_id: crate::FileId, + diagnostic_code: DiagnosticCode, + ) -> Vec { + ws.analysis.diagnostic.enable_only(diagnostic_code); + let diagnostics = ws + .analysis + .diagnose_file(file_id, CancellationToken::new()) + .unwrap_or_default(); + let code = Some(NumberOrString::String( + diagnostic_code.get_name().to_string(), + )); + diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == code) + .map(|diagnostic| diagnostic.message.clone()) + .collect() + } + fn local_name_type(ws: &mut VirtualWorkspace, file_id: crate::FileId, name: &str) -> LuaType { let semantic_model = ws .analysis @@ -78,6 +100,31 @@ mod test { .expect("expected semantic info for index expr") } + fn inferred_index_expr_type( + ws: &mut VirtualWorkspace, + file_id: crate::FileId, + expr_text: &str, + ) -> LuaType { + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("expected semantic model"); + + semantic_model + .get_root() + .descendants::() + .find_map(|node| match node { + LuaAst::LuaIndexExpr(index_expr) if index_expr.syntax().text() == expr_text => { + semantic_model + .infer_expr(LuaExpr::IndexExpr(index_expr)) + .ok() + } + _ => None, + }) + .expect("expected inferred type for index expr") + } + fn first_index_expr_member_owner( ws: &VirtualWorkspace, file_id: crate::FileId, @@ -470,6 +517,135 @@ mod test { ); } + #[gtest] + fn test_dynamic_key_read_from_known_table_fields_returns_child_table_value() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.infer_dynamic_fields = true; + ws.update_emmyrc(emmyrc); + + let file_id = ws.def_file( + "lua/glide/client/vehicle_layout_editor.lua", + r#" + ---@class CSEnt + local CSEnt = {} + function CSEnt:Remove() + end + + ---@param ent any + ---@return boolean + local function IsValid(ent) + end + + ---@param modelPath string + ---@param renderGroup any + ---@return CSEnt + local function ClientsideModel(modelPath, renderGroup) + end + + local PREVIEW_RENDER_GROUP = 0 + Glide = {} + local Editor = Glide.VehicleLayoutEditor or {} + Glide.VehicleLayoutEditor = Editor + + Editor.previewModels = Editor.previewModels or { + seats = {}, + wheels = {} + } + + function Editor:GetPreviewEntity(kind, itemId, modelPath) + if not modelPath or modelPath == "" then return end + self.previewModels = self.previewModels or { seats = {}, wheels = {} } + local pool = self.previewModels[kind] + if not pool then return end + local after_guard = pool + + local entry = pool[itemId] + if not entry or not IsValid(entry.ent) or entry.model ~= modelPath then + if entry and IsValid(entry.ent) then + entry.ent:Remove() + end + + local ent = ClientsideModel(modelPath, PREVIEW_RENDER_GROUP) + if not IsValid(ent) then + pool[itemId] = nil + return + end + + entry = { ent = {}, model = modelPath } + pool[itemId] = entry + end + + for _, value in pairs(pool) do + end + + return entry.ent + end + + function Editor:CleanupPreviewEntities(kind, usedMap) + if not self.previewModels then return end + local pool = self.previewModels[kind] + if not pool then return end + + for id, entry in pairs(pool) do + if not usedMap[id] then + if entry and IsValid(entry.ent) then + entry.ent:Remove() + end + pool[id] = nil + end + end + end + "#, + ); + + let pool_ty = local_name_type(&mut ws, file_id, "pool"); + assert!( + !ws.humanize_type_detailed(pool_ty.clone()) + .contains("[unknown]"), + "dynamic read of seats/wheels should not expose unknown-key object shape, got {} ({:?})", + ws.humanize_type_detailed(pool_ty.clone()), + pool_ty + ); + + let pool_expr_ty = index_expr_type(&mut ws, file_id, "self.previewModels[kind]"); + assert!( + !ws.humanize_type_detailed(pool_expr_ty.clone()) + .contains("[unknown]"), + "dynamic read expression should not expose unknown-key object shape, got {} ({:?})", + ws.humanize_type_detailed(pool_expr_ty.clone()), + pool_expr_ty + ); + + let direct_pool_expr_ty = + inferred_index_expr_type(&mut ws, file_id, "self.previewModels[kind]"); + assert!( + !ws.humanize_type_detailed(direct_pool_expr_ty.clone()) + .contains("[unknown]"), + "direct dynamic read inference should not expose unknown-key object shape, got {} ({:?})", + ws.humanize_type_detailed(direct_pool_expr_ty.clone()), + direct_pool_expr_ty + ); + + let after_guard_ty = local_name_type(&mut ws, file_id, "after_guard"); + assert_that!( + ws.check_type(&after_guard_ty, &LuaType::Table), + eq(true), + "expected guarded dynamic read to narrow to table, got {} ({:?})", + ws.humanize_type(after_guard_ty.clone()), + after_guard_ty + ); + + let diagnostics = + file_diagnostic_messages(&mut ws, file_id, DiagnosticCode::ParamTypeMismatch); + assert_that!( + diagnostics, + is_empty(), + "`pairs(pool)` should accept a guarded child table read through an unknown key" + ); + } + #[gtest] fn test_inferred_collection_integer_index_returns_element_union() { let mut ws = VirtualWorkspace::new_with_init_std_lib(); diff --git a/crates/glua_code_analysis/src/config/configs/hover.rs b/crates/glua_code_analysis/src/config/configs/hover.rs index 8f9cad91..3a7508c3 100644 --- a/crates/glua_code_analysis/src/config/configs/hover.rs +++ b/crates/glua_code_analysis/src/config/configs/hover.rs @@ -8,19 +8,12 @@ pub struct EmmyrcHover { #[serde(default = "default_true")] #[schemars(extend("x-vscode-setting" = true))] pub enable: bool, - - /// The detail number of hover information. - /// Default is `None`, which means using the default detail level. - /// You can set it to a number between `1` and `255` to customize - #[serde(default)] - pub custom_detail: Option, } impl Default for EmmyrcHover { fn default() -> Self { Self { enable: default_true(), - custom_detail: None, } } } diff --git a/crates/glua_code_analysis/src/db_index/type/humanize_type.rs b/crates/glua_code_analysis/src/db_index/type/humanize_type.rs index 04d93620..c79170ef 100644 --- a/crates/glua_code_analysis/src/db_index/type/humanize_type.rs +++ b/crates/glua_code_analysis/src/db_index/type/humanize_type.rs @@ -12,11 +12,14 @@ use crate::{ use super::{LuaAliasCallKind, LuaMultiLineUnion}; +pub const DEFAULT_DETAIL_MEMBER_DISPLAY_COUNT: usize = 6; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RenderLevel { Documentation, - // donot more than 255 - CustomDetailed(u8), + /// Like `Detailed`, but with a custom max display count for class members. + /// Used by the hover verbosity system to show progressively more members. + DetailedCount(usize), Detailed, Simple, Normal, @@ -28,7 +31,7 @@ impl RenderLevel { pub fn next_level(self) -> RenderLevel { match self { RenderLevel::Documentation => RenderLevel::Simple, - RenderLevel::CustomDetailed(_) => RenderLevel::Simple, + RenderLevel::DetailedCount(_) => RenderLevel::Simple, RenderLevel::Detailed => RenderLevel::Simple, RenderLevel::Simple => RenderLevel::Normal, RenderLevel::Normal => RenderLevel::Brief, @@ -44,9 +47,13 @@ fn hover_escape_string(s: &str) -> String { match ch { '\\' => out.push_str("\\\\"), '"' => out.push_str("\\\""), + '\u{07}' => out.push_str("\\a"), + '\u{08}' => out.push_str("\\b"), + '\u{0c}' => out.push_str("\\f"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), + '\u{0b}' => out.push_str("\\v"), '\u{1b}' => out.push_str("\\27"), ch if ch.is_control() => { let code = ch as u32; @@ -186,16 +193,18 @@ fn humanize_simple_type( ) -> Option { let max_display_count = match level { RenderLevel::Documentation => 500, - RenderLevel::CustomDetailed(n) => n as usize, - RenderLevel::Detailed => 12, + RenderLevel::DetailedCount(n) => n, + RenderLevel::Detailed => DEFAULT_DETAIL_MEMBER_DISPLAY_COUNT, _ => return Some(name.to_string()), }; let member_owner = LuaMemberOwner::Type(id.clone()); let member_index = db.get_member_index(); let members = member_index.get_sorted_members(&member_owner)?; - let mut member_vec = Vec::new(); + let all_count = members.len(); + let mut member_strings = String::new(); let mut function_vec = Vec::new(); + let mut count = 0; for member in members { let member_key = member.get_key(); let type_cache = db.get_type_index().get_type_cache(&member.get_id().into()); @@ -204,34 +213,31 @@ fn humanize_simple_type( None => &super::LuaTypeCache::InferType(LuaType::Any), }; if type_cache.is_function() { - function_vec.push(member_key); + if function_vec.len() < max_display_count { + function_vec.push(member_key); + } } else { - member_vec.push((member_key, type_cache.as_type())); + let typ = type_cache.as_type(); + let member_string = build_table_member_string( + db, + member_key, + typ, + humanize_type(db, typ, level.next_level()), + level, + ); + + member_strings.push_str(&format!(" {},\n", member_string)); + count += 1; + if count >= max_display_count { + break; + } } } - if member_vec.is_empty() && function_vec.is_empty() { + if all_count == 0 { return Some(name.to_string()); } - let all_count = member_vec.len() + function_vec.len(); - let mut member_strings = String::new(); - let mut count = 0; - for (member_key, typ) in member_vec { - let member_string = build_table_member_string( - db, - member_key, - typ, - humanize_type(db, typ, level.next_level()), - level, - ); - - member_strings.push_str(&format!(" {},\n", member_string)); - count += 1; - if count >= max_display_count { - break; - } - } if count < all_count { for function_key in function_vec { let member_string = build_table_member_string( @@ -272,8 +278,7 @@ where let types = union.into_vec(); let num = match level { RenderLevel::Documentation => 500, - RenderLevel::CustomDetailed(n) => n as usize, - RenderLevel::Detailed => 8, + RenderLevel::DetailedCount(_) | RenderLevel::Detailed => 8, RenderLevel::Simple => 6, RenderLevel::Normal => 4, RenderLevel::Brief => 2, @@ -326,8 +331,7 @@ fn humanize_multi_line_union_type( let num = match level { RenderLevel::Documentation => 500, - RenderLevel::CustomDetailed(n) => n as usize, - RenderLevel::Detailed => 10, + RenderLevel::DetailedCount(_) | RenderLevel::Detailed => 10, RenderLevel::Simple => 8, RenderLevel::Normal => 4, RenderLevel::Brief => 2, @@ -343,7 +347,7 @@ fn humanize_multi_line_union_type( .join("|"); let mut text = format!("({}{})", type_str, dots); - if level != RenderLevel::Detailed { + if !matches!(level, RenderLevel::DetailedCount(_) | RenderLevel::Detailed) { return text; } @@ -368,8 +372,7 @@ fn humanize_tuple_type(db: &DbIndex, tuple: &LuaTupleType, level: RenderLevel) - let types = tuple.get_types(); let num = match level { RenderLevel::Documentation => 500, - RenderLevel::CustomDetailed(n) => n as usize, - RenderLevel::Detailed => 10, + RenderLevel::DetailedCount(_) | RenderLevel::Detailed => 10, RenderLevel::Simple => 8, RenderLevel::Normal => 4, RenderLevel::Brief => 2, @@ -462,8 +465,7 @@ fn humanize_doc_function_type( fn humanize_object_type(db: &DbIndex, object: &LuaObjectType, level: RenderLevel) -> String { let num = match level { RenderLevel::Documentation => 500, - RenderLevel::CustomDetailed(n) => n as usize, - RenderLevel::Detailed => 10, + RenderLevel::DetailedCount(_) | RenderLevel::Detailed => 10, RenderLevel::Simple => 8, RenderLevel::Normal => 4, RenderLevel::Brief => 2, @@ -488,7 +490,9 @@ fn humanize_object_type(db: &DbIndex, object: &LuaObjectType, level: RenderLevel let ty_str = humanize_type(db, field.1, level.next_level()); match name { LuaMemberKey::Integer(i) => format!("[{}]: {}", i, ty_str), - LuaMemberKey::Name(s) => format!("{}: {}", s, ty_str), + LuaMemberKey::Name(s) => { + format!("{}: {}", humanize_member_key_name(s.as_str()), ty_str) + } LuaMemberKey::None => ty_str, LuaMemberKey::ExprType(_) => ty_str, } @@ -522,8 +526,7 @@ fn humanize_intersect_type( ) -> String { let num = match level { RenderLevel::Documentation => 500, - RenderLevel::CustomDetailed(n) => n as usize, - RenderLevel::Detailed => 10, + RenderLevel::DetailedCount(_) | RenderLevel::Detailed => 10, RenderLevel::Simple => 8, RenderLevel::Normal => 4, RenderLevel::Brief => 2, @@ -561,7 +564,7 @@ fn humanize_generic_type(db: &DbIndex, generic: &LuaGenericType, level: RenderLe let generic_base = format!("{}<{}>", full_name, generic_inst_params); if matches!( level, - RenderLevel::Documentation | RenderLevel::CustomDetailed(_) | RenderLevel::Detailed + RenderLevel::Documentation | RenderLevel::DetailedCount(_) | RenderLevel::Detailed ) && type_decl.is_alias() { let substituor = TypeSubstitutor::from_type_array(generic.get_params().clone()); @@ -575,14 +578,50 @@ fn humanize_generic_type(db: &DbIndex, generic: &LuaGenericType, level: RenderLe generic_base } +/// How a table-like type should be laid out at a given [`RenderLevel`]. +/// +/// `Detailed` is the multi-line block form; `Compact` is the inline +/// `{ a, b }` form. Returns `None` for levels that should collapse the table to +/// a bare `table` (Brief/Minimal), which also terminates nested recursion. +#[derive(Clone, Copy)] +enum TableLayout { + Detailed, + Compact, +} + +impl TableLayout { + fn from_level(level: RenderLevel) -> Option { + match level { + RenderLevel::Documentation | RenderLevel::DetailedCount(_) | RenderLevel::Detailed => { + Some(Self::Detailed) + } + // `Normal` reuses the compact inline form (rather than collapsing to + // a bare `table`) so nested table rows inside a `Simple`-rendered + // parent — e.g. field hovers, whose members render one level down at + // `Normal` — still show their shape instead of `table`. Recursion + // still terminates because `Normal.next_level()` is `Brief`, which + // maps to `None` below. + RenderLevel::Simple | RenderLevel::Normal => Some(Self::Compact), + RenderLevel::Brief | RenderLevel::Minimal => None, + } + } +} + fn humanize_table_const_type_detail_and_simple( db: &DbIndex, member_owned: LuaMemberOwner, level: RenderLevel, ) -> Option { + let layout = TableLayout::from_level(level)?; let member_index = db.get_member_index(); let members = member_index.get_sorted_members(&member_owned)?; + // Use the custom count from DetailedCount, or the compact default for Detailed. + let detailed_max = match level { + RenderLevel::DetailedCount(n) => n, + _ => DEFAULT_DETAIL_MEMBER_DISPLAY_COUNT, + }; + let mut total_length = 0; let mut total_line = 0; let mut members_string = String::new(); @@ -601,16 +640,16 @@ fn humanize_table_const_type_detail_and_simple( level, ); - match level { - RenderLevel::Detailed => { + match layout { + TableLayout::Detailed => { total_line += 1; members_string.push_str(&format!(" {},\n", member_string)); - if total_line >= 12 { + if total_line >= detailed_max { members_string.push_str(" ...\n"); break; } } - RenderLevel::Simple => { + TableLayout::Compact => { let member_string_len = member_string.chars().count(); if total_length != 0 { members_string.push_str(", "); @@ -624,15 +663,13 @@ fn humanize_table_const_type_detail_and_simple( break; } } - _ => return None, } } - match level { - RenderLevel::Detailed => Some(format!("{{\n{}}}", members_string)), - RenderLevel::Simple => Some(format!("{{ {} }}", members_string)), - _ => None, - } + Some(match layout { + TableLayout::Detailed => format!("{{\n{}}}", members_string), + TableLayout::Compact => format!("{{ {} }}", members_string), + }) } fn humanize_table_const_type( @@ -640,13 +677,11 @@ fn humanize_table_const_type( member_owned: LuaMemberOwner, level: RenderLevel, ) -> String { - match level { - RenderLevel::Detailed | RenderLevel::Simple => { - humanize_table_const_type_detail_and_simple(db, member_owned, level) - .unwrap_or("table".to_string()) - } - _ => "table".to_string(), + if TableLayout::from_level(level).is_none() { + return "table".to_string(); } + humanize_table_const_type_detail_and_simple(db, member_owned, level) + .unwrap_or("table".to_string()) } fn humanize_merged_table_type( @@ -655,7 +690,7 @@ fn humanize_merged_table_type( level: RenderLevel, ) -> String { match level { - RenderLevel::Detailed | RenderLevel::Simple => { + RenderLevel::DetailedCount(_) | RenderLevel::Detailed | RenderLevel::Simple => { let typ = LuaType::MergedTable(merged.clone().into()); let Some(members) = find_members(db, &typ) else { return "table".to_string(); @@ -684,10 +719,18 @@ fn humanize_member_list_as_table( ); match level { + RenderLevel::DetailedCount(n) => { + total_line += 1; + members_string.push_str(&format!(" {},\n", member_string)); + if total_line >= n { + members_string.push_str(" ...\n"); + break; + } + } RenderLevel::Detailed => { total_line += 1; members_string.push_str(&format!(" {},\n", member_string)); - if total_line >= 12 { + if total_line >= DEFAULT_DETAIL_MEMBER_DISPLAY_COUNT { members_string.push_str(" ...\n"); break; } @@ -711,7 +754,9 @@ fn humanize_member_list_as_table( } match level { - RenderLevel::Detailed => Some(format!("{{\n{}}}", members_string)), + RenderLevel::DetailedCount(_) | RenderLevel::Detailed => { + Some(format!("{{\n{}}}", members_string)) + } RenderLevel::Simple => Some(format!("{{ {} }}", members_string)), _ => None, } @@ -724,8 +769,7 @@ fn humanize_table_generic_type( ) -> String { let num = match level { RenderLevel::Documentation => 500, - RenderLevel::CustomDetailed(n) => n as usize, - RenderLevel::Detailed => 10, + RenderLevel::DetailedCount(_) | RenderLevel::Detailed => 10, RenderLevel::Simple => 8, RenderLevel::Normal => 4, RenderLevel::Brief => 2, @@ -788,8 +832,7 @@ fn humanize_variadic_type(db: &DbIndex, multi: &VariadicType, level: RenderLevel VariadicType::Multi(types) => { let max_num = match level { RenderLevel::Documentation => 500, - RenderLevel::CustomDetailed(n) => n as usize, - RenderLevel::Detailed => 10, + RenderLevel::DetailedCount(_) | RenderLevel::Detailed => 10, RenderLevel::Simple => 8, RenderLevel::Normal => 4, RenderLevel::Brief => 2, @@ -888,25 +931,31 @@ fn build_table_member_string( member_value_string: String, level: RenderLevel, ) -> String { - let (member_value, separator) = if level == RenderLevel::Detailed { - let val = match ty { - LuaType::IntegerConst(_) | LuaType::DocIntegerConst(_) => { - format!("integer = {member_value_string}") - } - LuaType::FloatConst(_) => format!("number = {member_value_string}"), - LuaType::StringConst(_) | LuaType::DocStringConst(_) => { - format!("string = {member_value_string}") - } - LuaType::BooleanConst(_) => format!("boolean = {member_value_string}"), - _ => member_value_string, + let (member_value, separator) = + if matches!(level, RenderLevel::DetailedCount(_) | RenderLevel::Detailed) { + let val = match ty { + LuaType::IntegerConst(_) | LuaType::DocIntegerConst(_) => { + format!("integer = {member_value_string}") + } + LuaType::FloatConst(_) => format!("number = {member_value_string}"), + LuaType::StringConst(_) | LuaType::DocStringConst(_) => { + format!("string = {member_value_string}") + } + LuaType::BooleanConst(_) => format!("boolean = {member_value_string}"), + _ => member_value_string, + }; + (val, ": ") + } else { + (member_value_string, " = ") }; - (val, ": ") - } else { - (member_value_string, " = ") - }; match member_key { - LuaMemberKey::Name(name) => format!("{name}{separator}{member_value}"), + LuaMemberKey::Name(name) => { + format!( + "{}{separator}{member_value}", + humanize_member_key_name(name.as_str()) + ) + } LuaMemberKey::Integer(i) => format!("[{i}]{separator}{member_value}"), LuaMemberKey::None => member_value, LuaMemberKey::ExprType(LuaType::Integer) => member_value, @@ -917,13 +966,66 @@ fn build_table_member_string( } } +pub fn humanize_member_key_name(name: &str) -> String { + if is_lua_identifier(name) && !is_lua_keyword(name) { + name.to_string() + } else { + format!("[\"{}\"]", hover_escape_string(name)) + } +} + +fn is_lua_identifier(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn is_lua_keyword(name: &str) -> bool { + matches!( + name, + "and" + | "break" + | "do" + | "else" + | "elseif" + | "end" + | "false" + | "for" + | "function" + | "goto" + | "if" + | "in" + | "local" + | "nil" + | "not" + | "or" + | "repeat" + | "return" + | "then" + | "true" + | "until" + | "while" + ) +} + #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{collections::HashMap, sync::Arc}; + + use googletest::prelude::*; - use crate::{LuaType, LuaUnionType}; + use smol_str::SmolStr; - use super::{RenderLevel, format_union_type}; + use crate::{DbIndex, LuaMemberKey, LuaObjectType, LuaType, LuaUnionType}; + + use super::{ + RenderLevel, build_table_member_string, format_union_type, humanize_member_key_name, + humanize_type, + }; fn simple_type_label(ty: &LuaType) -> String { match ty { @@ -936,7 +1038,7 @@ mod tests { } } - #[test] + #[gtest] fn format_union_type_sorts_members_consistently() { let left = LuaUnionType::from_vec(vec![LuaType::String, LuaType::Number, LuaType::Boolean]); let right = @@ -947,11 +1049,11 @@ mod tests { let right_render = format_union_type(&right, RenderLevel::Detailed, |ty, _| simple_type_label(ty)); - assert_eq!(left_render, right_render); - assert_eq!(left_render, "(boolean|number|string)"); + expect_eq!(left_render, right_render); + expect_eq!(left_render, "(boolean|number|string)"); } - #[test] + #[gtest] fn format_union_type_keeps_nullable_suffix_with_canonical_order() { let union = LuaType::Union(Arc::new(LuaUnionType::from_vec(vec![ LuaType::String, @@ -967,6 +1069,62 @@ mod tests { unreachable!("expected union type") }; - assert_eq!(rendered, "(number|string)?"); + expect_eq!(rendered, "(number|string)?"); + } + + #[gtest] + fn humanize_member_key_name_uses_bare_names_only_for_valid_identifiers() { + expect_eq!(humanize_member_key_name("valid_name1"), "valid_name1"); + expect_eq!(humanize_member_key_name("end"), "[\"end\"]"); + expect_eq!(humanize_member_key_name("not valid"), "[\"not valid\"]"); + } + + #[gtest] + fn table_member_string_escapes_control_character_keys() { + let db = DbIndex::default(); + let cases = [ + ("\u{07}", "\\a", r#"["\a"]: string = "\\a""#), + ("\u{08}", "\\b", r#"["\b"]: string = "\\b""#), + ("\u{0c}", "\\f", r#"["\f"]: string = "\\f""#), + ("\n", "\\n", r#"["\n"]: string = "\\n""#), + ("\r", "\\r", r#"["\r"]: string = "\\r""#), + ("\t", "\\t", r#"["\t"]: string = "\\t""#), + ("\u{0b}", "\\v", r#"["\v"]: string = "\\v""#), + ("\\", "\\\\", r#"["\\"]: string = "\\\\""#), + ("\"", "\\\"", r#"["\""]: string = "\\\"""#), + ("'", "\\'", r#"["'"]: string = "\\'""#), + ]; + + for (key, value, expected) in cases { + let value_type = LuaType::StringConst(SmolStr::new(value).into()); + let rendered = build_table_member_string( + &db, + &LuaMemberKey::Name(key.into()), + &value_type, + humanize_type(&db, &value_type, RenderLevel::Detailed), + RenderLevel::Detailed, + ); + expect_eq!(rendered, expected); + } + } + + #[gtest] + fn object_type_escapes_control_character_field_names() { + let db = DbIndex::default(); + let object = LuaType::Object( + LuaObjectType::new_with_fields( + HashMap::from([( + LuaMemberKey::Name("\n".into()), + LuaType::StringConst(SmolStr::new("\\n").into()), + )]), + Vec::new(), + ) + .into(), + ); + + expect_eq!( + humanize_type(&db, &object, RenderLevel::Detailed), + r#"{ ["\n"]: "\\n" }"# + ); } } diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index f8ee31c7..ed7d50d7 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -12,7 +12,10 @@ use crate::{ DbIndex, FileId, InFiled, LuaMemberOwner, db_index::r#type::type_decl::LuaTypeIdentifier, }; pub use generic_param::GenericParam; -pub use humanize_type::{RenderLevel, format_union_type, humanize_type}; +pub use humanize_type::{ + DEFAULT_DETAIL_MEMBER_DISPLAY_COUNT, RenderLevel, format_union_type, humanize_member_key_name, + humanize_type, +}; use rowan::TextRange; use std::collections::{HashMap, HashSet}; pub use type_decl::{LuaDeclLocation, LuaDeclTypeKind, LuaTypeDecl, LuaTypeDeclId, LuaTypeFlag}; 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 6ba8e5b1..a517184b 100644 --- a/crates/glua_code_analysis/src/db_index/type/types.rs +++ b/crates/glua_code_analysis/src/db_index/type/types.rs @@ -13,6 +13,8 @@ use crate::{ db_index::{LuaMemberKey, LuaSignatureId, r#type::type_visit_trait::TypeVisitTrait}, first_param_may_not_self, }; +use glua_parser::{LuaAstNode, LuaTableExpr}; +use rowan::NodeOrToken; use super::{GenericParam, TypeOps, type_decl::LuaTypeDeclId}; @@ -649,6 +651,81 @@ impl From for LuaType { } } +/// Checks whether an `InFiled` for a `TableConst` resolves back to a +/// `LuaTableExpr` whose `is_shaped_array_literal()` is true. This ensures that +/// only true shaped sequential table literals (all integer-keyed, no named fields, +/// each value is itself a table literal) receive array-like `TableConst` +/// treatment. +/// +/// Uses the existing syntax lookup path: `db.get_vfs().get_syntax_tree()` → +/// `get_red_root()` → `covering_element()` → walk up to `LuaTableExpr`. +pub fn is_table_const_shaped_array(db: &DbIndex, range: &InFiled) -> bool { + let Some(tree) = db.get_vfs().get_syntax_tree(&range.file_id) else { + return false; + }; + let root = tree.get_red_root(); + + let mut node = match root.covering_element(range.value) { + NodeOrToken::Node(node) => Some(node), + NodeOrToken::Token(token) => token.parent(), + }; + + while let Some(current) = node { + if let Some(table_expr) = LuaTableExpr::cast(current.clone()) { + return table_expr.get_range() == range.value && table_expr.is_shaped_array_literal(); + } + node = current.parent(); + } + + false +} + +/// Derive the array element base type for a `TableConst` produced from a shaped +/// sequential table literal (see `infer_table_expr`). +/// +/// Iterates the integer-keyed members `[1..=n]` and unions their resolved types, +/// applying the same literal-widening as [`LuaTupleType::cast_down_array_base`] +/// (`IntegerConst` → `DocIntegerConst`, `FloatConst` → `number`, `StringConst` → +/// `DocStringConst`). Returns `None` when the table has no integer members or +/// when the provenance check (`is_table_const_shaped_array`) fails, so callers +/// can fall back to their existing behavior. Bounded by the shaped-literal +/// member cap, so this stays cheap. +pub fn table_const_array_base(db: &DbIndex, range: &InFiled) -> Option { + if !is_table_const_shaped_array(db, range) { + return None; + } + + let owner = crate::LuaMemberOwner::Element(range.clone()); + let member_index = db.get_member_index(); + let members = member_index.get_members(&owner)?; + + let mut ty = LuaType::Unknown; + let mut saw_integer_member = false; + for member in members { + if !matches!(member.get_key(), LuaMemberKey::Integer(_)) { + continue; + } + // Read the inferred type from the type cache rather than `resolve_type`, + // which can fail with a NeedResolve error mid-analysis. An unresolved + // member falls back to `Unknown`, matching other member-reading sites. + let member_type = db + .get_type_index() + .get_type_cache(&member.get_id().into()) + .map(|cache| cache.as_type().clone()) + .unwrap_or(LuaType::Unknown); + saw_integer_member = true; + let widened = match &member_type { + LuaType::IntegerConst(int) => LuaType::DocIntegerConst(*int), + LuaType::FloatConst(_) => LuaType::Number, + LuaType::StringConst(s) => LuaType::DocStringConst(s.clone()), + _ => member_type, + }; + ty = TypeOps::Union.apply(db, &ty, &widened); + } + + saw_integer_member.then_some(ty) +} + #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct LuaFunctionType { async_state: AsyncState, diff --git a/crates/glua_code_analysis/src/diagnostic/checker/assign_type_mismatch.rs b/crates/glua_code_analysis/src/diagnostic/checker/assign_type_mismatch.rs index c70e0423..696ec6fd 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/assign_type_mismatch.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/assign_type_mismatch.rs @@ -2,14 +2,14 @@ use std::ops::Deref; use glua_parser::{ BinaryOperator, LuaAssignStat, LuaAst, LuaAstNode, LuaAstToken, LuaCommentOwner, LuaDocTag, - LuaExpr, LuaIndexExpr, LuaLiteralToken, LuaLocalStat, LuaNameExpr, LuaSyntaxNode, + LuaExpr, LuaIndexExpr, LuaIndexKey, LuaLiteralToken, LuaLocalStat, LuaNameExpr, LuaSyntaxNode, LuaSyntaxToken, LuaTableExpr, LuaTableField, LuaVarExpr, NumberResult, PathTrait, UnaryOperator, }; use rowan::{NodeOrToken, TextRange}; use crate::{ - DiagnosticCode, LuaDeclExtra, LuaDeclId, LuaMemberKey, LuaSemanticDeclId, LuaType, + DbIndex, DiagnosticCode, LuaDeclExtra, LuaDeclId, LuaMemberKey, LuaSemanticDeclId, LuaType, SemanticDeclLevel, SemanticModel, TypeCheckFailReason, TypeCheckResult, VariadicType, infer_index_expr, }; @@ -163,13 +163,17 @@ fn check_index_expr( .map(|(is_inferred, _)| is_inferred) .unwrap_or(false); - let source_type = infer_index_expr( - semantic_model.get_db(), - &mut semantic_model.get_cache().borrow_mut(), - index_expr.clone(), - false, - ) - .ok(); + // Prefer the pre-write member type to avoid the current assignment + // widening the target field type before comparison. + let source_type = pre_write_index_expr_type(semantic_model, index_expr).or_else(|| { + infer_index_expr( + semantic_model.get_db(), + &mut semantic_model.get_cache().borrow_mut(), + index_expr.clone(), + false, + ) + .ok() + }); check_assign_type_mismatch( context, @@ -198,6 +202,97 @@ fn check_index_expr( Some(()) } +/// Resolve the **pre-write** source type for an indexed assignment target by +/// looking up the member type before the current write. This avoids the +/// current assignment widening the target field type before comparison. +/// +/// For `TableConst` prefix types (table literals) that are truly mixed in +/// their **original syntax** — containing both integer-style entries +/// (implicit array fields or explicit integer keys) and named fields — +/// resolves the field type from the literal AST rather than the member +/// index, since the member index may have been updated by the current write. +/// +/// Returns `None` when the prefix type, key, or field cannot be resolved, +/// or when the original literal is not mixed, causing the caller to fall +/// back to the normal `infer_index_expr` path. +fn pre_write_index_expr_type( + semantic_model: &SemanticModel, + index_expr: &LuaIndexExpr, +) -> Option { + let prefix_expr = index_expr.get_prefix_expr()?; + let prefix_type = semantic_model.infer_expr(prefix_expr).ok()?; + + let LuaType::TableConst(in_file_range) = &prefix_type else { + return None; + }; + + // Get the table literal's AST node from the source range. + let root = semantic_model + .get_db() + .get_vfs() + .get_syntax_tree(&in_file_range.file_id)?; + let red_root = root.get_red_root(); + let table_node = red_root.covering_element(in_file_range.value).into_node()?; + let table_expr = LuaTableExpr::cast(table_node)?; + + // Only apply the pre-write strict path when the **original literal + // syntax** is truly mixed — has both integer-style entries and named + // fields. This check uses the AST, not the member index, so later + // numeric writes cannot retroactively turn a pure named-field literal + // into a "mixed" one. + if !table_literal_is_mixed(&table_expr) { + return None; + } + + let index_key = index_expr.get_index_key()?; + let member_key = LuaMemberKey::from_index_key( + semantic_model.get_db(), + &mut semantic_model.get_cache().borrow_mut(), + &index_key, + ) + .ok()?; + + // Find the field matching the key and infer its value type directly + // from the literal AST — this is the pre-write type. + for field in table_expr.get_fields() { + let field_key = field.get_field_key()?; + let field_member_key = semantic_model.get_member_key(&field_key)?; + if field_member_key == member_key { + let value_expr = field.get_value_expr()?; + return semantic_model.infer_expr(value_expr).ok(); + } + } + + None +} + +/// Whether a table literal's **original syntax** is mixed — contains both +/// integer-style entries (implicit value fields or explicit integer keys) +/// and named fields (name or string keys). Used to detect shaped table +/// literals where named fields should remain strictly typed. +fn table_literal_is_mixed(table_expr: &LuaTableExpr) -> bool { + let mut has_integer_style = false; + let mut has_named = false; + + for field in table_expr.get_fields() { + if field.is_value_field() { + has_integer_style = true; + } else if let Some(key) = field.get_field_key() { + match key { + LuaIndexKey::Integer(_) | LuaIndexKey::Idx(_) => has_integer_style = true, + LuaIndexKey::Name(_) | LuaIndexKey::String(_) => has_named = true, + LuaIndexKey::Expr(_) => {} + } + } + + if has_integer_style && has_named { + return true; + } + } + + false +} + fn inferred_target_requires_explicit_table_field_checks(expr: &LuaExpr) -> bool { let Some(table_expr) = LuaTableExpr::cast(expr.syntax().clone()) else { return false; @@ -447,15 +542,12 @@ where return Some((false, false)); } - if !is_lenient_inferred_member_type(type_cache.as_type()) { + let db = semantic_model.get_db(); + if !is_lenient_inferred_member_type(db, type_cache.as_type()) { all_lenient = false; } - let is_collection = match type_cache.as_type() { - LuaType::Array(_) => true, - LuaType::Tuple(tuple) => tuple.is_infer_resolve(), - _ => false, - }; + let is_collection = is_inferred_collection_member_type(db, type_cache.as_type()); if !is_collection { all_collection = false; } @@ -464,11 +556,29 @@ where Some((all_lenient, all_collection)) } -fn is_lenient_inferred_member_type(typ: &LuaType) -> bool { +/// Whether an inferred member type is a sequential collection (array-like). +/// +/// Shaped sequential table literals infer as `TableConst` with integer members, +/// so they qualify; keyed/object `TableConst` literals (no integer members) do +/// NOT, preserving strict field-mismatch checking for them. +fn is_inferred_collection_member_type(db: &DbIndex, typ: &LuaType) -> bool { + match typ { + LuaType::Array(_) => true, + LuaType::Tuple(tuple) => tuple.is_infer_resolve(), + LuaType::TableConst(range) => crate::table_const_array_base(db, range).is_some(), + _ => false, + } +} + +fn is_lenient_inferred_member_type(db: &DbIndex, typ: &LuaType) -> bool { matches!( typ, LuaType::Nil | LuaType::Unknown | LuaType::Never | LuaType::Array(_) ) || matches!(typ, LuaType::Tuple(tuple) if tuple.is_infer_resolve()) + // Shaped sequential literals infer as TableConst and are mutable dynamic + // tables, so later modification must not be flagged. Object/keyed + // TableConst literals are excluded so their fields stay strictly checked. + || matches!(typ, LuaType::TableConst(range) if crate::table_const_array_base(db, range).is_some()) } fn check_local_stat( diff --git a/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs b/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs index 2dd211ab..0b076a9a 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs @@ -2536,6 +2536,58 @@ return t "# )); } + + /// Named fields on a shaped literal must stay strict even when the literal + /// also has integer members. Assigning a wrong type to `.kind` should error. + #[test] + fn test_mixed_literal_named_field_stay_strict_with_integer_members() { + let mut ws = VirtualWorkspace::new(); + assert!(!ws.check_code_for( + DiagnosticCode::AssignTypeMismatch, + r#" + local mixed = { { id = 1 }, kind = "metadata" } + mixed.kind = 42 + "# + )); + } + + /// A numeric-key object with named fields must keep named fields strict. + /// Assigning a wrong type to `.name` should error. + #[test] + fn test_numeric_key_object_named_field_stays_strict() { + let mut ws = VirtualWorkspace::new(); + assert!(!ws.check_code_for( + DiagnosticCode::AssignTypeMismatch, + r#" + local obj = { [100] = { id = 1 }, name = "x" } + obj.name = 42 + "# + )); + } + + /// A simple inferred named-field literal should remain lenient even when + /// later code writes a numeric member. Widening an inferred table to accept + /// both named fields and numeric-indexed entries must not produce + /// AssignTypeMismatch for the existing named field. + #[test] + fn test_inferred_named_field_literal_lenient_with_later_numeric_member() { + let mut ws = VirtualWorkspace::new(); + assert!(ws.check_code_for( + DiagnosticCode::AssignTypeMismatch, + r#" + ---@class Vector + ---@return Vector + local function Vector() end + + local cfg = { + color = "white", + } + + cfg[1] = { id = 1 } + cfg.color = Vector() + "# + )); + } } #[test] diff --git a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs index bd65adae..35604b6b 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs @@ -974,6 +974,110 @@ mod test { ); } + #[gtest] + fn test_reverse_len_for_loop_index_on_plain_table_has_no_nil_access_diagnostic_in_strict_array_mode() + { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.strict.array_index = true; + ws.update_emmyrc(emmyrc); + + let code = r#" + ---@param myWeapons table + local function clear(myWeapons) + if not myWeapons then + return + end + + for i = #myWeapons, 1, -1 do + myWeapons[i]:OnRemove() + myWeapons[i] = nil + end + end + "#; + + assert_that!( + ws.check_code_for(DiagnosticCode::UncheckedNilAccess, code), + eq(true) + ); + assert_that!( + ws.check_code_for(DiagnosticCode::NeedCheckNil, code), + eq(true) + ); + } + + #[gtest] + fn test_reverse_len_for_loop_index_on_guarded_class_table_field_has_no_nil_access_diagnostic() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.strict.array_index = true; + ws.update_emmyrc(emmyrc); + + let code = r#" + ---@class Vehicle + ---@field weapons table? + local Vehicle = {} + + function Vehicle:ClearWeapons() + local myWeapons = self.weapons + if not myWeapons then + return + end + + for i = #myWeapons, 1, -1 do + myWeapons[i]:OnRemove() + myWeapons[i] = nil + end + end + "#; + + assert_that!( + ws.check_code_for(DiagnosticCode::UncheckedNilAccess, code), + eq(true) + ); + assert_that!( + ws.check_code_for(DiagnosticCode::NeedCheckNil, code), + eq(true) + ); + } + + #[gtest] + fn test_reverse_len_for_loop_index_on_empty_table_const_alias_has_no_nil_access_diagnostic() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.strict.array_index = true; + ws.update_emmyrc(emmyrc); + + let code = r#" + local ENT = {} + + function ENT:Initialize() + self.weapons = {} + end + + function ENT:ClearWeapons() + local myWeapons = self.weapons + if not myWeapons then + return + end + + for i = #myWeapons, 1, -1 do + myWeapons[i]:OnRemove() + myWeapons[i] = nil + end + end + "#; + + assert_that!( + ws.check_code_for(DiagnosticCode::UncheckedNilAccess, code), + eq(true) + ); + assert_that!( + ws.check_code_for(DiagnosticCode::NeedCheckNil, code), + eq(true) + ); + } + #[gtest] fn test_reverse_len_for_loop_index_with_zero_bound_still_reports_nil_access() { let mut ws = VirtualWorkspace::new(); diff --git a/crates/glua_code_analysis/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index a8c5c6da..33cb67f6 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -81,6 +81,7 @@ pub struct LuaInferCache { pub flow_query_realm: Option, pub flow_node_realm_cache: FxHashMap, pub index_ref_origin_type_cache: FxHashMap>, + pub param_type_cache: FxHashMap>, pub expr_var_ref_id_cache: FxHashMap, pub narrow_by_literal_stop_position_cache: HashSet, pub scoped_scripted_global_cache: Option>, @@ -202,6 +203,7 @@ impl LuaInferCache { flow_query_realm: None, flow_node_realm_cache: FxHashMap::default(), index_ref_origin_type_cache: FxHashMap::default(), + param_type_cache: FxHashMap::default(), expr_var_ref_id_cache: FxHashMap::default(), narrow_by_literal_stop_position_cache: HashSet::new(), scoped_scripted_global_cache: None, @@ -328,6 +330,7 @@ impl LuaInferCache { self.flow_query_realm = None; self.flow_node_realm_cache.clear(); self.index_ref_origin_type_cache.clear(); + self.param_type_cache.clear(); self.expr_var_ref_id_cache.clear(); self.scoped_scripted_global_cache = None; self.pending_str_tpl_type_decls.clear(); diff --git a/crates/glua_code_analysis/src/semantic/generic/call_constraint.rs b/crates/glua_code_analysis/src/semantic/generic/call_constraint.rs index 65653dd5..d68974b7 100644 --- a/crates/glua_code_analysis/src/semantic/generic/call_constraint.rs +++ b/crates/glua_code_analysis/src/semantic/generic/call_constraint.rs @@ -62,6 +62,10 @@ pub fn build_call_constraint_context( pub fn normalize_constraint_type(db: &DbIndex, ty: LuaType) -> LuaType { match ty { LuaType::Tuple(tuple) if tuple.is_infer_resolve() => tuple.cast_down_array_base(db), + // Shaped sequential literals infer as TableConst; normalize to their + // array element base for generic argument matching, matching the prior + // tuple-based behavior. + LuaType::TableConst(ref range) => crate::table_const_array_base(db, range).unwrap_or(ty), _ => ty, } } diff --git a/crates/glua_code_analysis/src/semantic/generic/test.rs b/crates/glua_code_analysis/src/semantic/generic/test.rs index 30c78dd3..c64e1439 100644 --- a/crates/glua_code_analysis/src/semantic/generic/test.rs +++ b/crates/glua_code_analysis/src/semantic/generic/test.rs @@ -4,8 +4,8 @@ mod test { use smol_str::SmolStr; use crate::{ - DiagnosticCode, GenericTpl, GenericTplId, LuaMergedTableType, LuaType, TypeSubstitutor, - VirtualWorkspace, instantiate_type_generic, + DiagnosticCode, GenericTpl, GenericTplId, LuaMergedTableType, LuaType, RenderLevel, + TypeSubstitutor, VirtualWorkspace, humanize_type, instantiate_type_generic, }; #[test] @@ -225,7 +225,11 @@ result = { ); let a = ws.expr_ty("result"); - let a_desc = ws.humanize_type_detailed(a); + let a_desc = humanize_type( + ws.analysis.compilation.get_db(), + &a, + RenderLevel::DetailedCount(12), + ); let expected = r#"{ direct: string, class_a: string, diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs b/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs index cd30fc0c..13cdedcd 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs @@ -4,8 +4,8 @@ pub(crate) use infer_array::check_iter_var_range; use std::collections::HashSet; use glua_parser::{ - LuaAstNode, LuaCallExpr, LuaExpr, LuaIndexExpr, LuaIndexKey, LuaIndexMemberExpr, LuaLocalStat, - LuaNameExpr, NumberResult, PathTrait, + LuaAstNode, LuaCallExpr, LuaExpr, LuaForStat, LuaIndexExpr, LuaIndexKey, LuaIndexMemberExpr, + LuaLocalStat, LuaNameExpr, NumberResult, PathTrait, }; use internment::ArcIntern; use rowan::{TextRange, TextSize}; @@ -336,6 +336,9 @@ fn infer_table_member( Err(err) if is_unknown_dynamic_key_without_table_data(db, &owner, &inst, &index_key, &err) => { + if is_dynamic_index_in_len_for_range(db, cache, &index_expr, &index_key) { + return Ok(LuaType::Any); + } return Ok(nullable_any_type()); } Err(err) => return Err(err), @@ -348,6 +351,18 @@ fn infer_table_member( ); } + // Dynamic numeric index (e.g. `t[i]` where `i` is an `integer`) on a shaped + // sequential literal: no exact `[n]` member matches, so fall back to the + // unioned array element base. Mirrors the tuple dynamic-index behavior so + // shaped table-of-table literals keep their per-row element type under a + // non-constant index. + if dynamic_numeric_index_key(&key) + && let Some(base) = + resolve_table_const_array_base(db, cache, &owner, index_expr.get_position())? + { + return Ok(base); + } + if let Some(member_type) = infer_cross_file_matching_expr_key_member_type( db, &owner, @@ -398,6 +413,9 @@ fn infer_table_member( return Ok(dynamic_field.typ); } if is_dynamic_expr_key_without_table_data(db, &owner, &inst, &key) { + if is_dynamic_index_in_len_for_range(db, cache, &index_expr, &index_key) { + return Ok(LuaType::Any); + } return Ok(nullable_any_type()); } if let Ok(global_path_type) = @@ -630,6 +648,38 @@ fn is_dynamic_expr_key_without_table_data( matches!(key, LuaMemberKey::ExprType(_)) && table_const_has_no_specific_data(db, owner, inst) } +fn is_dynamic_index_in_len_for_range( + db: &DbIndex, + cache: &mut LuaInferCache, + index_expr: &LuaIndexMemberExpr, + index_key: &LuaIndexKey, +) -> bool { + let LuaIndexKey::Expr(expr) = index_key else { + return false; + }; + + if !matches!(expr, LuaExpr::NameExpr(_) | LuaExpr::UnaryExpr(_)) { + return false; + }; + + if !is_inside_numeric_for_stat(index_expr) { + return false; + } + + let Some(prefix_expr) = index_expr.get_prefix_expr() else { + return false; + }; + check_iter_var_range(db, cache, expr, prefix_expr).unwrap_or(false) +} + +fn is_inside_numeric_for_stat(index_expr: &LuaIndexMemberExpr) -> bool { + index_expr + .syntax() + .ancestors() + .skip(1) + .any(|ancestor| LuaForStat::cast(ancestor).is_some()) +} + fn table_const_has_no_specific_data( db: &DbIndex, owner: &LuaMemberOwner, @@ -1720,28 +1770,43 @@ fn infer_member_by_index_table( None => { let index_key = index_expr.get_index_key().ok_or(InferFailReason::None)?; let key_type = index_key_access_type(db, cache, &index_key)?; - let members = db - .get_member_index() - .get_members(&LuaMemberOwner::Element(table_range.clone())); + let owner = LuaMemberOwner::Element(table_range.clone()); + let members = db.get_member_index().get_members(&owner); if let Some(mut members) = members { members.sort_by(|a, b| a.get_key().cmp(b.get_key())); let mut result_type = LuaType::Unknown; let mut matched_inferred_index_key = false; + // Track matches independently of the union being non-Unknown: + // a matched member whose type is (temporarily) Unknown must not + // be confused with "no member matched". This keeps shaped + // sequential TableConst rows indexable via the operator path + // (MergedTable/Union components), mirroring `infer_table_member`. + let mut saw_match = false; for member in members { if member_key_matches_type(db, &key_type, member.get_key()) { + saw_match = true; matched_inferred_index_key |= is_inferred_index_member_key(member.get_key()); + // Resolve the member type instead of reading the raw + // cache, which may still be Unknown mid-analysis. let member_type = db - .get_type_index() - .get_type_cache(&member.get_id().into()) - .map(|it| it.as_type()) - .unwrap_or(&LuaType::Unknown); - - result_type = TypeOps::Union.apply(db, &result_type, member_type); + .get_member_index() + .get_member_item(&owner, member.get_key()) + .and_then(|item| { + item.resolve_type_with_realm_at_offset( + db, + &cache.get_file_id(), + index_expr.get_position(), + ) + .ok() + }) + .unwrap_or(LuaType::Unknown); + + result_type = TypeOps::Union.apply(db, &result_type, &member_type); } } - if !result_type.is_unknown() { + if saw_match { if table_index_result_may_be_nil(db, &key_type, matched_inferred_index_key) { result_type = TypeOps::Union.apply(db, &result_type, &LuaType::Nil); } @@ -1779,6 +1844,75 @@ fn is_inferred_index_member_key(key: &LuaMemberKey) -> bool { matches!(key, LuaMemberKey::ExprType(_)) } +/// Whether a member key is a non-constant numeric index (`integer`/`number`), +/// as opposed to a constant `[n]`. Used to decide when to fall back to a +/// `TableConst`'s unioned array element base. +fn dynamic_numeric_index_key(key: &LuaMemberKey) -> bool { + matches!( + key, + LuaMemberKey::ExprType(LuaType::Integer | LuaType::Number) + ) +} + +/// Resolve the array element base of a shaped sequential `TableConst` by +/// unioning the *resolved* types of its integer-keyed members. +/// +/// Unlike [`crate::table_const_array_base`] (which reads the type cache and may +/// see `Unknown` mid-analysis), this resolves each member via +/// `resolve_type_with_realm_at_offset` so dynamic indexing (`t[i]`) stays +/// precise even when the member's cache is not yet populated — e.g. when the +/// table is reached through a `MergedTable`/`Union` component. +/// +/// Returns: +/// - `Ok(None)` when there are no integer members (caller falls back). +/// - `Err(reason)` when a member is not yet resolvable, so the inference engine +/// reschedules instead of silently degrading to `Unknown`. +fn resolve_table_const_array_base( + db: &DbIndex, + cache: &LuaInferCache, + owner: &LuaMemberOwner, + position: TextSize, +) -> Result, InferFailReason> { + // Only true shaped sequential table literals should receive array-like + // treatment. Mixed/object literals with integer-keyed members must not + // fall back to an array element union. + if let LuaMemberOwner::Element(range) = owner { + if !crate::is_table_const_shaped_array(db, range) { + return Ok(None); + } + } + + let Some(members) = db.get_member_index().get_members(owner) else { + return Ok(None); + }; + let mut ty = LuaType::Unknown; + let mut saw_integer_member = false; + for member in members { + if !matches!(member.get_key(), LuaMemberKey::Integer(_)) { + continue; + } + saw_integer_member = true; + let member_type = match db + .get_member_index() + .get_member_item(owner, member.get_key()) + { + Some(item) => { + item.resolve_type_with_realm_at_offset(db, &cache.get_file_id(), position)? + } + None => LuaType::Unknown, + }; + let widened = match &member_type { + LuaType::IntegerConst(int) => LuaType::DocIntegerConst(*int), + LuaType::FloatConst(_) => LuaType::Number, + LuaType::StringConst(s) => LuaType::DocStringConst(s.clone()), + _ => member_type, + }; + ty = TypeOps::Union.apply(db, &ty, &widened); + } + + Ok(saw_integer_member.then_some(ty)) +} + fn infer_member_by_index_custom_type( db: &DbIndex, cache: &mut LuaInferCache, diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs index 34ff942a..94d18297 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs @@ -743,11 +743,42 @@ pub fn infer_param(db: &DbIndex, decl: &LuaDecl) -> InferResult { Err(InferFailReason::UnResolveDeclType(decl.get_id())) } -fn infer_param_type_from_gmod_name_hint(db: &DbIndex, param_name: &str) -> Option { - if !db.get_emmyrc().gmod.enabled { - return None; +pub fn infer_param_with_cache( + db: &DbIndex, + cache: &mut LuaInferCache, + decl: &LuaDecl, +) -> InferResult { + let decl_id = decl.get_id(); + if let Some(cache_entry) = cache.param_type_cache.get(&decl_id) { + return match cache_entry { + CacheEntry::Cache(typ) => Ok(typ.clone()), + CacheEntry::Error(reason) => Err(reason.clone()), + CacheEntry::Ready => Err(InferFailReason::RecursiveInfer), + }; + } + + cache.param_type_cache.insert(decl_id, CacheEntry::Ready); + let result = infer_param(db, decl); + match &result { + Ok(typ) => { + cache + .param_type_cache + .insert(decl_id, CacheEntry::Cache(typ.clone())); + } + Err(reason) if cache.get_config().analysis_phase.is_diagnostics() => { + cache + .param_type_cache + .insert(decl_id, CacheEntry::Error(reason.clone())); + } + Err(_) => { + cache.param_type_cache.remove(&decl_id); + } } + result +} + +fn infer_param_type_from_gmod_name_hint(db: &DbIndex, param_name: &str) -> Option { let hints = &db.get_emmyrc().gmod.file_param_defaults; if hints.is_empty() { return None; @@ -766,10 +797,6 @@ fn infer_param_type_from_gmod_name_hint(db: &DbIndex, param_name: &str) -> Optio } fn infer_param_type_from_file_hint(db: &DbIndex, decl: &LuaDecl) -> Option { - if !db.get_emmyrc().gmod.enabled { - return None; - } - let target_name = decl.get_name().to_ascii_lowercase(); let type_text = db .get_gmod_infer_index() @@ -1563,9 +1590,15 @@ mod test { let db = ws.analysis.compilation.get_db(); let mut cache = LuaInferCache::new(file_id, Default::default()); - expect_that!(cache.expr_var_ref_id_cache.contains_key(&syntax_id), eq(false)); + expect_that!( + cache.expr_var_ref_id_cache.contains_key(&syntax_id), + eq(false) + ); expect_that!(infer_name_expr(db, &mut cache, self_expr).is_ok(), eq(true)); - expect_that!(cache.expr_var_ref_id_cache.contains_key(&syntax_id), eq(true)); + expect_that!( + cache.expr_var_ref_id_cache.contains_key(&syntax_id), + eq(true) + ); Ok(()) } diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_table.rs b/crates/glua_code_analysis/src/semantic/infer/infer_table.rs index 0d01f6e7..4913a529 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_table.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_table.rs @@ -22,8 +22,20 @@ pub fn infer_table_expr( cache: &mut LuaInferCache, table: LuaTableExpr, ) -> InferResult { + // A sequential literal whose rows are themselves table literals carries + // meaningful per-index shape. Materialize it as a dynamic table (TableConst) + // so integer-keyed members (`[1]`, `[2]`, ...) hold the rich row shapes and + // the table stays mutable. Simple scalar arrays fall through to the array + // summary path below so they remain `T[]`. + if table.is_shaped_array_literal() { + return Ok(LuaType::TableConst(crate::InFiled { + file_id: cache.get_file_id(), + value: table.get_range(), + })); + } + if table.is_array() { - return infer_table_tuple_or_array(db, cache, table); + return infer_table_array_summary(db, cache, table); } Ok(LuaType::TableConst(crate::InFiled { @@ -32,7 +44,19 @@ pub fn infer_table_expr( })) } -fn infer_table_tuple_or_array( +/// Summarize a sequential ("array-style") table literal that is NOT a shaped +/// table-of-tables (see [`LuaTableExpr::is_shaped_array_literal`]). +/// +/// Small scalar/mixed literals (`{1, 2, 3}`, `{ self, "player" }`) are inferred +/// as an infer-resolve [`LuaType::Tuple`]. Despite the name, this is NOT an +/// immutable tuple value: it is an internal *positional-evidence carrier* that +/// preserves exact per-index types so machinery like `table.unpack`, +/// `std.Unpack`, multi-value assignment, and positional `[1]`/`[2]` field +/// checks stay precise. Mutation of such a table is treated leniently elsewhere +/// (see `assign_type_mismatch`), so it behaves as a dynamic table for +/// diagnostics. Large literals collapse to `T[]`; dots/variadic spreads are +/// handled as before. +fn infer_table_array_summary( db: &DbIndex, cache: &mut LuaInferCache, table: LuaTableExpr, @@ -108,6 +132,9 @@ fn infer_table_tuple_or_array( }; } + // Small scalar/mixed sequential literal: retain exact per-index types as an + // infer-resolve tuple (a positional-evidence carrier; see the function doc). + // Mutation leniency is handled in the diagnostic layer. let mut types = Vec::new(); for field in fields { let value_expr = field.get_value_expr().ok_or(InferFailReason::None)?; diff --git a/crates/glua_code_analysis/src/semantic/infer/mod.rs b/crates/glua_code_analysis/src/semantic/infer/mod.rs index 20db9a13..aa2f09dc 100644 --- a/crates/glua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/mod.rs @@ -24,11 +24,11 @@ pub(crate) use infer_index::check_iter_var_range; pub use infer_index::infer_index_expr; pub(crate) use infer_index::infer_member_by_member_key; pub(crate) use infer_index::resolve_decl_backed_global_path_member_type; -use infer_name::infer_name_expr; pub(crate) use infer_name::infer_enclosing_self_type; +use infer_name::infer_name_expr; pub(crate) use infer_name::resolve_scoped_scripted_global_type_decl_id; pub(crate) use infer_name::try_local_decl_initializer_fallback_type; -pub use infer_name::{find_self_decl_or_member_id, infer_param}; +pub use infer_name::{find_self_decl_or_member_id, infer_param, infer_param_with_cache}; use infer_table::infer_table_expr; pub use infer_table::{infer_table_field_value_should_be, infer_table_should_be}; use infer_unary::infer_unary_expr; @@ -232,7 +232,11 @@ pub fn infer_expr(db: &DbIndex, cache: &mut LuaInferCache, expr: LuaExpr) -> Inf result_type } -fn infer_literal_expr(db: &DbIndex, config: &LuaInferCache, expr: LuaLiteralExpr) -> InferResult { +fn infer_literal_expr( + db: &DbIndex, + config: &mut LuaInferCache, + expr: LuaLiteralExpr, +) -> InferResult { match expr.get_literal().ok_or(InferFailReason::None)? { LuaLiteralToken::Nil(_) => Ok(LuaType::Nil), LuaLiteralToken::Bool(bool) => Ok(LuaType::BooleanConst(bool.is_true())), @@ -256,7 +260,7 @@ fn infer_literal_expr(db: &DbIndex, config: &LuaInferCache, expr: LuaLiteralExpr let decl_type = match decl_id.and_then(|id| db.get_decl_index().get_decl(&id)) { Some(decl) if decl.is_global() => LuaType::Any, Some(decl) if decl.is_param() => { - let base = infer_param(db, decl).unwrap_or(LuaType::Unknown); + let base = infer_param_with_cache(db, config, decl).unwrap_or(LuaType::Unknown); LuaType::Variadic(VariadicType::Base(base).into()) } _ => LuaType::Variadic(VariadicType::Base(LuaType::Any).into()), diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs index f2e5a9e8..860caaa8 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs @@ -12,8 +12,7 @@ use crate::{ LuaMemberOwner, LuaSemanticDeclId, LuaSignatureId, LuaType, LuaTypeOwner, LuaUnionType, TypeOps, infer_expr, semantic::infer::{ - InferResult, VarRefId, infer_expr_list_value_type_at, - infer_name::infer_param, + InferResult, VarRefId, infer_expr_list_value_type_at, infer_param_with_cache, narrow::{ ResultTypeOrContinue, condition_flow::{InferConditionFlow, get_type_at_condition_flow}, @@ -491,7 +490,7 @@ fn get_decl_position_var_ref_type( && let Some(decl) = db.get_decl_index().get_decl(&decl_id) { if decl.is_param() - && let Ok(param_type) = infer_param(db, decl) + && let Ok(param_type) = infer_param_with_cache(db, cache, decl) { return Ok(param_type); } @@ -1046,9 +1045,7 @@ fn get_type_at_assign_stat( // doc `@type` override) so each region keeps its own table identity. let rhs_is_fresh_table_literal = explicit_var_type.is_none() && matches!(expr_type, LuaType::TableConst(_)) - && exprs - .get(i) - .is_some_and(expr_is_table_constructor); + && exprs.get(i).is_some_and(expr_is_table_constructor); let narrowed = if rhs_is_fresh_table_literal { Some(expr_type.clone()) @@ -1278,7 +1275,7 @@ fn is_inferred_member_collection_expr( if !type_cache.is_infer() { return Ok(false); } - if normalize_infer_collection_type(type_cache.as_type()).is_none() { + if normalize_infer_collection_type(db, type_cache.as_type()).is_none() { return Ok(false); } saw_collection = true; @@ -1296,10 +1293,13 @@ fn get_member_owner_for_prefix_type(prefix_type: LuaType) -> Option Option<()> { +fn normalize_infer_collection_type(db: &DbIndex, typ: &LuaType) -> Option<()> { match typ { LuaType::Array(_) => Some(()), LuaType::Tuple(tuple) if tuple.is_infer_resolve() => Some(()), + // Shaped sequential literals are inferred as TableConst; treat them as + // inferred collections too so flow narrowing over their elements works. + LuaType::TableConst(range) => crate::table_const_array_base(db, range).map(|_| ()), _ => None, } } @@ -1308,6 +1308,7 @@ fn infer_collection_base_type(db: &DbIndex, typ: &LuaType) -> Option { match typ { LuaType::Array(array) => Some(array.get_base().clone()), LuaType::Tuple(tuple) if tuple.is_infer_resolve() => Some(tuple.cast_down_array_base(db)), + LuaType::TableConst(range) => crate::table_const_array_base(db, range), _ => None, } } diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs index a75fbe96..ea5a2b83 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs @@ -8,7 +8,7 @@ use crate::{ CacheEntry, DbIndex, FlowAntecedent, FlowId, FlowNode, FlowNodeKind, FlowTree, InferFailReason, LuaInferCache, LuaType, TypeOps, db_index::LuaTypeDeclId, - get_real_type, infer_param, + get_real_type, infer_param_with_cache, semantic::infer::{ InferResult, infer_name::{find_decl_member_type, infer_global_type}, @@ -169,7 +169,7 @@ pub fn get_var_ref_type( // Flow/assignment analysis may also create a decl type cache entry for params, // but that inferred cache must not replace the declared parameter type. if decl.is_param() { - if let Ok(param_type) = infer_param(db, decl) { + if let Ok(param_type) = infer_param_with_cache(db, cache, decl) { return Ok(param_type); } diff --git a/crates/glua_code_analysis/src/semantic/infer/test.rs b/crates/glua_code_analysis/src/semantic/infer/test.rs index 8d3bf527..1a47e99b 100644 --- a/crates/glua_code_analysis/src/semantic/infer/test.rs +++ b/crates/glua_code_analysis/src/semantic/infer/test.rs @@ -1071,6 +1071,54 @@ mod test { assert!(!ty.is_unknown()); } + /// `table_const_array_base` should only return `Some` for true shaped + /// sequential literals (all integer-keyed, no named fields). Mixed tables + /// with named fields or sparse numeric keys must return `None`. + #[test] + fn test_table_const_array_base_only_matches_shaped_sequential_literals() { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def( + r#" + local shaped = { { id = 1 }, { id = 2 } } + local mixed = { { id = 1 }, kind = "metadata" } + local numeric_object = { [100] = { id = 1 }, name = "x" } + print(shaped, mixed, numeric_object) + "#, + ); + + let db = ws.analysis.compilation.get_db(); + + // shaped: two sequential elements, no named fields → Some(...) + let shaped_ty = infer_last_name_expr_type_in_file(&ws, file_id, "shaped"); + let LuaType::TableConst(shaped_range) = shaped_ty else { + panic!("expected TableConst for shaped, got: {shaped_ty:?}"); + }; + assert!( + crate::table_const_array_base(db, &shaped_range).is_some(), + "shaped sequential literal should be treated as array-like" + ); + + // mixed: has integer [1] but also named field `kind` → None + let mixed_ty = infer_last_name_expr_type_in_file(&ws, file_id, "mixed"); + let LuaType::TableConst(mixed_range) = mixed_ty else { + panic!("expected TableConst for mixed, got: {mixed_ty:?}"); + }; + assert!( + crate::table_const_array_base(db, &mixed_range).is_none(), + "mixed literal with named fields should not be treated as array-like" + ); + + // numeric_object: has [100] but also named field `name` → None + let numeric_ty = infer_last_name_expr_type_in_file(&ws, file_id, "numeric_object"); + let LuaType::TableConst(numeric_range) = numeric_ty else { + panic!("expected TableConst for numeric_object, got: {numeric_ty:?}"); + }; + assert!( + crate::table_const_array_base(db, &numeric_range).is_none(), + "numeric-key object with named fields should not be treated as array-like" + ); + } + #[test] fn test_reassigned_local_table_resolves_to_new_table_identity() { // General Lua semantics (no GMod): after `t = {}` the local must hold the diff --git a/crates/glua_code_analysis/src/semantic/mod.rs b/crates/glua_code_analysis/src/semantic/mod.rs index f9e38134..e47d1dea 100644 --- a/crates/glua_code_analysis/src/semantic/mod.rs +++ b/crates/glua_code_analysis/src/semantic/mod.rs @@ -63,13 +63,13 @@ use crate::{LuaFunctionType, LuaMemberId, LuaMemberKey, LuaTypeOwner}; pub use generic::*; pub use guard::{InferGuard, InferGuardRef}; pub use infer::InferFailReason; -pub use infer::{SelfRefId, VarRefId, VarRefRootId}; pub use infer::infer_call_expr_func; pub(crate) use infer::infer_enclosing_self_type; pub(crate) use infer::infer_expr; -pub use infer::infer_param; pub(crate) use infer::remove_false_or_nil; +pub use infer::{SelfRefId, VarRefId, VarRefRootId}; pub(crate) use infer::{contains_gmod_null_type, get_var_expr_var_ref_id}; +pub use infer::{infer_param, infer_param_with_cache}; use overload_resolve::resolve_signature; pub use semantic_info::SemanticDeclLevel; pub use type_check::{TypeCheckFailReason, TypeCheckResult}; diff --git a/crates/glua_code_analysis/src/semantic/semantic_info/mod.rs b/crates/glua_code_analysis/src/semantic/semantic_info/mod.rs index d4ce7b76..33879242 100644 --- a/crates/glua_code_analysis/src/semantic/semantic_info/mod.rs +++ b/crates/glua_code_analysis/src/semantic/semantic_info/mod.rs @@ -5,7 +5,6 @@ mod semantic_guard; use crate::{ DbIndex, LuaDeclExtra, LuaDeclId, LuaMemberId, LuaSemanticDeclId, LuaType, LuaTypeCache, - TypeOps, }; use glua_parser::{ LuaAstNode, LuaAstToken, LuaDocNameType, LuaDocTag, LuaExpr, LuaLocalName, LuaParamName, @@ -17,7 +16,9 @@ pub use semantic_decl_level::SemanticDeclLevel; pub use semantic_guard::SemanticDeclGuard; use super::infer::try_local_decl_initializer_fallback_type; -use super::{InferFailReason, LuaInferCache, infer_bind_value_type, infer_expr}; +use super::{ + InferFailReason, LuaInferCache, infer_bind_value_type, infer_expr, infer_param_with_cache, +}; #[derive(Debug, Clone, PartialEq)] pub struct SemanticInfo { @@ -58,15 +59,8 @@ pub fn infer_token_semantic_info( let decl_id = LuaDeclId::new(file_id, token.text_range().start()); let decl = db.get_decl_index().get_decl(&decl_id)?; match &decl.extra { - LuaDeclExtra::Param { - idx, signature_id, .. - } => { - let signature = db.get_signature_index().get(signature_id)?; - let param_info = signature.get_param_info_by_id(*idx)?; - let mut typ = param_info.type_ref.clone(); - if param_info.nullable && !typ.is_nullable() { - typ = TypeOps::Union.apply(db, &typ, &LuaType::Nil); - } + LuaDeclExtra::Param { .. } => { + let typ = infer_param_with_cache(db, cache, decl).ok()?; Some(SemanticInfo { typ, diff --git a/crates/glua_code_analysis/src/semantic/type_check/complex_type/table_generic_check.rs b/crates/glua_code_analysis/src/semantic/type_check/complex_type/table_generic_check.rs index 5dd054cb..5d5312d4 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/complex_type/table_generic_check.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/complex_type/table_generic_check.rs @@ -79,6 +79,18 @@ pub fn check_table_generic_type_compact( return Ok(()); } } + LuaType::MergedTable(merged_table) => { + for component in merged_table.get_types() { + check_table_generic_type_compact( + context, + source_generic_param, + component, + check_guard.next_level()?, + )?; + } + + return Ok(()); + } LuaType::Userdata => return Ok(()), // maybe support object // need check later diff --git a/crates/glua_code_analysis/src/semantic/type_check/test.rs b/crates/glua_code_analysis/src/semantic/type_check/test.rs index dc8c2e51..f220aee2 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/test.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/test.rs @@ -200,6 +200,15 @@ mod test { assert!(check_type_compact(&db, &source, &compact).is_ok()); } + #[test] + fn test_table_generic_accepts_merged_table_argument() { + let db = crate::DbIndex::new(); + let source = LuaType::TableGeneric(vec![LuaType::Any, LuaType::Any].into()); + let compact = LuaMergedTableType::new(vec![LuaType::Table]).into(); + + assert!(check_type_compact(&db, &source, &compact).is_ok()); + } + fn merged_table_with_field(name: &str, typ: LuaType) -> LuaType { merged_table_from_object(object_with_field(name, typ)) } diff --git a/crates/glua_ls/src/context/file_diagnostic.rs b/crates/glua_ls/src/context/file_diagnostic.rs index 85e783a1..f1249b4b 100644 --- a/crates/glua_ls/src/context/file_diagnostic.rs +++ b/crates/glua_ls/src/context/file_diagnostic.rs @@ -13,6 +13,8 @@ use lsp_types::{Diagnostic, PublishDiagnosticsParams, Uri}; use tokio::sync::{Mutex, RwLock, Semaphore}; use tokio_util::sync::CancellationToken; +use crate::util::{LongRunningWatchdogStatus, spawn_long_running_watchdog}; + use super::{ClientProxy, ProgressTask, StatusBar}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -79,6 +81,7 @@ pub struct FileDiagnostic { analysis: Arc>, client: Arc, status_bar: Arc, + workspace_loaded_notified: Arc, startup_complete_notified: Arc, diagnostic_tokens: Arc>>, workspace_diagnostic_token: Arc>>, @@ -96,6 +99,7 @@ impl FileDiagnostic { Self { analysis, client, + workspace_loaded_notified: Arc::new(AtomicBool::new(false)), startup_complete_notified: Arc::new(AtomicBool::new(false)), diagnostic_tokens: Arc::new(Mutex::new(HashMap::new())), workspace_diagnostic_token: Arc::new(Mutex::new(None)), @@ -176,15 +180,29 @@ impl FileDiagnostic { self.recently_edited_lines.lock().await.remove(uri); } + pub fn notify_workspace_loaded(&self) { + if self.workspace_loaded_notified.swap(true, Ordering::AcqRel) { + return; + } + + info!("workspace loaded; language server is ready while diagnostics may continue"); + self.send_server_status("workspaceLoaded"); + } + fn notify_startup_complete(&self) { if self.startup_complete_notified.swap(true, Ordering::AcqRel) { return; } + info!("workspace diagnostics complete; language server startup fully complete"); + self.send_server_status("startupComplete"); + } + + fn send_server_status(&self, state: &'static str) { self.client.send_notification( "gluals/serverStatus", serde_json::json!({ - "state": "startupComplete", + "state": state, }), ); } @@ -455,6 +473,11 @@ impl FileDiagnostic { &self, cancel_token: CancellationToken, ) -> Vec<(Uri, Vec)> { + info!("workspace diagnostic pull slow started"); + let watchdog_status = + LongRunningWatchdogStatus::new("Preparing workspace diagnostics (slow pull)"); + let _watchdog = + spawn_long_running_watchdog("workspace diagnostics", watchdog_status.clone()); let mut token = self.workspace_diagnostic_token.lock().await; if let Some(token) = token.as_ref() { token.cancel(); @@ -464,6 +487,17 @@ impl FileDiagnostic { drop(token); let mut result = Vec::new(); + let status_bar = self.status_bar.clone(); + status_bar + .create_progress_task(ProgressTask::DiagnoseWorkspace) + .await; + status_bar.update_progress_task( + ProgressTask::DiagnoseWorkspace, + None, + Some(String::from("Preparing diagnostics")), + ); + watchdog_status.set_phase("Preparing workspace diagnostics (slow pull)"); + let (main_workspace_file_ids, shared_data) = { let analysis = self.analysis.read().await; let file_ids = analysis @@ -471,11 +505,27 @@ impl FileDiagnostic { .get_db() .get_module_index() .get_main_workspace_file_ids(); + info!( + "precomputing shared diagnostic data for {} workspace files", + file_ids.len() + ); let shared_data = self.force_precompute_shared_diagnostic_data(&analysis); (file_ids, shared_data) }; let (tx, mut rx) = tokio::sync::mpsc::channel::, Uri)>>(100); let valid_file_count = main_workspace_file_ids.len(); + if valid_file_count != 0 { + status_bar.update_progress_task( + ProgressTask::DiagnoseWorkspace, + Some(0), + Some(format!("Diagnosing 0/{}", valid_file_count)), + ); + watchdog_status.set_progress( + "Diagnosing workspace files (slow pull)", + 0, + valid_file_count, + ); + } let semaphore = Arc::new(Semaphore::new(workspace_diagnostic_parallelism())); for file_id in main_workspace_file_ids { @@ -499,6 +549,7 @@ impl FileDiagnostic { drop(tx); let mut count = 0; + let mut last_percentage = 0; while count < valid_file_count { tokio::select! { _ = cancel_token.cancelled() => { @@ -513,6 +564,24 @@ impl FileDiagnostic { result.push((uri, diagnostics)); } count += 1; + let percentage_done = ((count as f32 / valid_file_count as f32) * 100.0) as u32; + if last_percentage != percentage_done { + last_percentage = percentage_done; + let message = format!( + "Diagnosing {}/{} ({}%)", + count, valid_file_count, percentage_done + ); + status_bar.update_progress_task( + ProgressTask::DiagnoseWorkspace, + Some(percentage_done), + Some(message), + ); + watchdog_status.set_progress( + "Diagnosing workspace files (slow pull)", + count, + valid_file_count, + ); + } } } } @@ -523,6 +592,21 @@ impl FileDiagnostic { ); } + status_bar.finish_progress_task( + ProgressTask::DiagnoseWorkspace, + Some("Diagnostics complete".to_string()), + ); + if count == valid_file_count && !cancel_token.is_cancelled() { + self.notify_startup_complete(); + } else { + info!( + "workspace diagnostic pull slow finished without startup completion: completed={} expected={} cancelled={}", + count, + valid_file_count, + cancel_token.is_cancelled() + ); + } + result } @@ -530,6 +614,11 @@ impl FileDiagnostic { &self, cancel_token: CancellationToken, ) -> Vec<(Uri, Vec)> { + info!("workspace diagnostic pull fast started"); + let watchdog_status = + LongRunningWatchdogStatus::new("Preparing workspace diagnostics (fast pull)"); + let _watchdog = + spawn_long_running_watchdog("workspace diagnostics", watchdog_status.clone()); let mut token = self.workspace_diagnostic_token.lock().await; if let Some(token) = token.as_ref() { token.cancel(); @@ -539,6 +628,17 @@ impl FileDiagnostic { drop(token); let mut result = Vec::new(); + let status_bar = self.status_bar.clone(); + status_bar + .create_progress_task(ProgressTask::DiagnoseWorkspace) + .await; + status_bar.update_progress_task( + ProgressTask::DiagnoseWorkspace, + None, + Some(String::from("Preparing diagnostics")), + ); + watchdog_status.set_phase("Preparing workspace diagnostics (fast pull)"); + let (main_workspace_file_ids, shared_data) = { let analysis = self.analysis.read().await; let file_ids = analysis @@ -546,17 +646,28 @@ impl FileDiagnostic { .get_db() .get_module_index() .get_main_workspace_file_ids(); + info!( + "precomputing shared diagnostic data for {} workspace files", + file_ids.len() + ); let shared_data = self.force_precompute_shared_diagnostic_data(&analysis); (file_ids, shared_data) }; - let status_bar = self.status_bar.clone(); - status_bar - .create_progress_task(ProgressTask::DiagnoseWorkspace) - .await; - let (tx, mut rx) = tokio::sync::mpsc::channel::, Uri)>>(100); let valid_file_count = main_workspace_file_ids.len(); + if valid_file_count != 0 { + status_bar.update_progress_task( + ProgressTask::DiagnoseWorkspace, + Some(0), + Some(format!("Diagnosing 0/{}", valid_file_count)), + ); + watchdog_status.set_progress( + "Diagnosing workspace files (fast pull)", + 0, + valid_file_count, + ); + } let analysis = self.analysis.clone(); let semaphore = Arc::new(Semaphore::new(workspace_diagnostic_parallelism())); @@ -602,12 +713,20 @@ impl FileDiagnostic { let percentage_done = ((count as f32 / valid_file_count as f32) * 100.0) as u32; if last_percentage != percentage_done { last_percentage = percentage_done; - let message = format!("diagnostic {}%", percentage_done); + let message = format!( + "Diagnosing {}/{} ({}%)", + count, valid_file_count, percentage_done + ); status_bar.update_progress_task( ProgressTask::DiagnoseWorkspace, Some(percentage_done), Some(message), ); + watchdog_status.set_progress( + "Diagnosing workspace files (fast pull)", + count, + valid_file_count, + ); } } } @@ -622,10 +741,17 @@ impl FileDiagnostic { status_bar.finish_progress_task( ProgressTask::DiagnoseWorkspace, - Some("Diagnosis complete".to_string()), + Some("Diagnostics complete".to_string()), ); if count == valid_file_count && !cancel_token.is_cancelled() { self.notify_startup_complete(); + } else { + info!( + "workspace diagnostic pull fast finished without startup completion: completed={} expected={} cancelled={}", + count, + valid_file_count, + cancel_token.is_cancelled() + ); } result @@ -709,6 +835,21 @@ async fn push_workspace_diagnostic( silent: bool, cancel_token: CancellationToken, ) { + info!("workspace diagnostic push started; silent={}", silent); + let watchdog_status = LongRunningWatchdogStatus::new("Preparing workspace diagnostics (push)"); + let _watchdog = spawn_long_running_watchdog("workspace diagnostics", watchdog_status.clone()); + if !silent { + status_bar + .create_progress_task(ProgressTask::DiagnoseWorkspace) + .await; + status_bar.update_progress_task( + ProgressTask::DiagnoseWorkspace, + None, + Some(String::from("Preparing diagnostics")), + ); + } + watchdog_status.set_phase("Preparing workspace diagnostics (push)"); + let (main_workspace_file_ids, shared_data) = { let read_analysis = analysis.read().await; let file_ids = read_analysis @@ -716,16 +857,25 @@ async fn push_workspace_diagnostic( .get_db() .get_module_index() .get_main_workspace_file_ids(); + info!( + "precomputing shared diagnostic data for {} workspace files", + file_ids.len() + ); let shared_data = file_diagnostic.force_precompute_shared_diagnostic_data(&read_analysis); (file_ids, shared_data) }; // diagnostic files let (tx, mut rx) = tokio::sync::mpsc::channel::(100); let valid_file_count = main_workspace_file_ids.len(); - if !silent { - status_bar - .create_progress_task(ProgressTask::DiagnoseWorkspace) - .await; + if !silent && valid_file_count != 0 { + status_bar.update_progress_task( + ProgressTask::DiagnoseWorkspace, + Some(0), + Some(format!("Diagnosing 0/{}", valid_file_count)), + ); + } + if valid_file_count != 0 { + watchdog_status.set_progress("Diagnosing workspace files (push)", 0, valid_file_count); } let semaphore = Arc::new(Semaphore::new(workspace_diagnostic_parallelism())); @@ -760,29 +910,37 @@ async fn push_workspace_diagnostic( let mut count = 0; if valid_file_count != 0 { - if silent { - while (rx.recv().await).is_some() { - count += 1; - if count == valid_file_count { + let mut last_percentage = 0; + while count < valid_file_count { + tokio::select! { + _ = cancel_token.cancelled() => { break; } - } - } else { - let mut last_percentage = 0; - while (rx.recv().await).is_some() { - count += 1; - let percentage_done = ((count as f32 / valid_file_count as f32) * 100.0) as u32; - if last_percentage != percentage_done { - last_percentage = percentage_done; - let message = format!("diagnostic {}%", percentage_done); - status_bar.update_progress_task( - ProgressTask::DiagnoseWorkspace, - Some(percentage_done), - Some(message), - ); - } - if count == valid_file_count { - break; + maybe_file_id = rx.recv() => { + if maybe_file_id.is_none() { + break; + } + count += 1; + let percentage_done = ((count as f32 / valid_file_count as f32) * 100.0) as u32; + if last_percentage != percentage_done { + last_percentage = percentage_done; + if !silent { + let message = format!( + "Diagnosing {}/{} ({}%)", + count, valid_file_count, percentage_done + ); + status_bar.update_progress_task( + ProgressTask::DiagnoseWorkspace, + Some(percentage_done), + Some(message), + ); + } + watchdog_status.set_progress( + "Diagnosing workspace files (push)", + count, + valid_file_count, + ); + } } } } @@ -797,10 +955,18 @@ async fn push_workspace_diagnostic( if !silent { status_bar.finish_progress_task( ProgressTask::DiagnoseWorkspace, - Some("Diagnosis complete".to_string()), + Some("Diagnostics complete".to_string()), ); if count == valid_file_count && !cancel_token.is_cancelled() { file_diagnostic.notify_startup_complete(); + } else { + info!( + "workspace diagnostic push finished without startup completion: completed={} expected={} cancelled={} silent={}", + count, + valid_file_count, + cancel_token.is_cancelled(), + silent + ); } } } @@ -860,7 +1026,7 @@ mod tests { use crate::context::{ClientProxy, StatusBar}; use glua_code_analysis::{DiagnosticCode, EmmyLuaAnalysis, Emmyrc, file_path_to_uri}; use googletest::prelude::*; - use lsp_server::Connection; + use lsp_server::{Connection, Message}; use lsp_types::NumberOrString; use lsp_types::{Position, Range}; use std::sync::Arc; @@ -932,6 +1098,42 @@ mod tests { Ok(()) } + #[gtest] + fn workspace_loaded_notification_does_not_suppress_startup_complete() -> Result<()> { + let (connection, peer) = Connection::memory(); + let client = Arc::new(ClientProxy::new(connection)); + let status_bar = Arc::new(StatusBar::new(client.clone())); + let analysis = Arc::new(RwLock::new(EmmyLuaAnalysis::new())); + let file_diagnostic = FileDiagnostic::new(analysis, status_bar, client); + + file_diagnostic.notify_workspace_loaded(); + file_diagnostic.notify_workspace_loaded(); + file_diagnostic.notify_startup_complete(); + + let statuses = peer + .receiver + .try_iter() + .filter_map(|message| match message { + Message::Notification(notification) + if notification.method == "gluals/serverStatus" => + { + notification + .params + .get("state") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + } + _ => None, + }) + .collect::>(); + + verify_that!( + statuses.as_slice(), + eq(&["workspaceLoaded".to_string(), "startupComplete".to_string()]) + )?; + Ok(()) + } + #[gtest] fn single_file_diagnostics_use_shared_workspace_data() -> Result<()> { let mut analysis = EmmyLuaAnalysis::new(); diff --git a/crates/glua_ls/src/context/status_bar.rs b/crates/glua_ls/src/context/status_bar.rs index a8976453..ae689f4c 100644 --- a/crates/glua_ls/src/context/status_bar.rs +++ b/crates/glua_ls/src/context/status_bar.rs @@ -28,9 +28,9 @@ impl ProgressTask { pub fn get_task_name(&self) -> &'static str { match self { - ProgressTask::LoadWorkspace => "Load workspace", - ProgressTask::DiagnoseWorkspace => "Diagnose workspace", - ProgressTask::RefreshIndex => "Refresh index", + ProgressTask::LoadWorkspace => "Loading workspace", + ProgressTask::DiagnoseWorkspace => "Running diagnostics", + ProgressTask::RefreshIndex => "Refreshing index", } } } @@ -103,3 +103,24 @@ impl StatusBar { ) } } + +#[cfg(test)] +mod tests { + use super::ProgressTask; + + #[test] + fn progress_task_names_are_concise() { + assert_eq!( + ProgressTask::LoadWorkspace.get_task_name(), + "Loading workspace" + ); + assert_eq!( + ProgressTask::DiagnoseWorkspace.get_task_name(), + "Running diagnostics" + ); + assert_eq!( + ProgressTask::RefreshIndex.get_task_name(), + "Refreshing index" + ); + } +} diff --git a/crates/glua_ls/src/context/workspace_manager.rs b/crates/glua_ls/src/context/workspace_manager.rs index 08c0b2fb..7ab02edc 100644 --- a/crates/glua_ls/src/context/workspace_manager.rs +++ b/crates/glua_ls/src/context/workspace_manager.rs @@ -8,6 +8,7 @@ use super::{ClientProxy, FileDiagnostic, StatusBar}; use crate::codestyle::{apply_editorconfig_file, apply_workspace_code_style}; use crate::context::lsp_features::LspFeatures; use crate::handlers::{ClientConfig, init_analysis}; +use crate::util::{LongRunningWatchdogStatus, spawn_long_running_watchdog}; use glua_code_analysis::uri_to_file_path; use glua_code_analysis::{ EmmyLuaAnalysis, Emmyrc, LuaDiagnosticConfig, WorkspaceFolder, WorkspaceImport, @@ -113,6 +114,9 @@ impl WorkspaceManager { } let config_roots = collect_config_roots(&workspace_folders, Some(file_dir.clone())); + let watchdog_status = LongRunningWatchdogStatus::new("Reloading GLuaLS configuration"); + let _watchdog = + spawn_long_running_watchdog("workspace config reload", watchdog_status.clone()); let loaded = load_emmy_config(config_roots, client_config); apply_workspace_code_style(&workspace_folders, loaded.emmyrc.as_ref()); @@ -134,6 +138,7 @@ impl WorkspaceManager { loaded.emmyrc, loaded.workspace_diagnostic_configs, loaded.workspace_emmyrcs, + watchdog_status, ) .await; client.refresh_semantic_tokens(); @@ -166,6 +171,9 @@ impl WorkspaceManager { let client = self.client.clone(); let workspace_diagnostic_status = self.workspace_diagnostic_level.clone(); tokio::spawn(async move { + let watchdog_status = LongRunningWatchdogStatus::new("Reloading workspace"); + let _watchdog = + spawn_long_running_watchdog("workspace reload", watchdog_status.clone()); apply_workspace_code_style(&workspace_folders, loaded.emmyrc.as_ref()); // Refresh per-root matchers before re-indexing. @@ -186,6 +194,7 @@ impl WorkspaceManager { loaded.emmyrc, loaded.workspace_diagnostic_configs, loaded.workspace_emmyrcs, + watchdog_status, ) .await; diff --git a/crates/glua_ls/src/handlers/document_link/build_link.rs b/crates/glua_ls/src/handlers/document_link/build_link.rs index 81dc2262..30a6bb0f 100644 --- a/crates/glua_ls/src/handlers/document_link/build_link.rs +++ b/crates/glua_ls/src/handlers/document_link/build_link.rs @@ -37,7 +37,7 @@ fn try_build_file_link( } let file_path = token.get_value(); - if file_path.find(['\\', '/']).is_some() { + if file_path.find(['\\', '/']).is_some() && has_linkable_path_component(&file_path) { let suffix_path = PathBuf::from(file_path); if suffix_path.exists() { if let Some(uri) = file_path_to_uri(&suffix_path) { @@ -75,6 +75,11 @@ fn try_build_file_link( Some(()) } +fn has_linkable_path_component(path: &str) -> bool { + path.split(['\\', '/']) + .any(|component| !component.is_empty() && component != "." && component != "..") +} + fn try_build_module_link( db: &DbIndex, token: LuaStringToken, @@ -109,3 +114,39 @@ pub fn is_require_path(token: LuaStringToken) -> Option { Some(call_expr.is_require()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::handlers::test_lib::{ProviderVirtualWorkspace, check}; + use googletest::prelude::*; + + #[gtest] + fn separator_only_paths_are_not_linkable() { + for path in [r"\", r"\\", "/"] { + expect_that!(has_linkable_path_component(path), eq(false)); + } + } + + #[gtest] + fn paths_with_real_components_are_linkable() { + for path in ["materials/icon.png", r"materials\icon.png", "/lua/autorun"] { + expect_that!(has_linkable_path_component(path), eq(true)); + } + } + + #[gtest] + fn escaped_backslash_string_does_not_create_document_link() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let file_id = ws.def(r#"local value = "\\\\""#); + let semantic_model = check!(ws.analysis.compilation.get_semantic_model(file_id)); + let links = check!(build_links( + semantic_model.get_db(), + semantic_model.get_root().syntax().clone(), + &semantic_model.get_document(), + )); + + expect_that!(links, is_empty()); + Ok(()) + } +} diff --git a/crates/glua_ls/src/handlers/hover/build_hover.rs b/crates/glua_ls/src/handlers/hover/build_hover.rs index 0740df0c..4378a82a 100644 --- a/crates/glua_ls/src/handlers/hover/build_hover.rs +++ b/crates/glua_ls/src/handlers/hover/build_hover.rs @@ -1,11 +1,11 @@ use std::collections::HashSet; -use glua_code_analysis::humanize_type; use glua_code_analysis::{ DbIndex, LuaCompilation, LuaDeclExtra, LuaDeclId, LuaDocument, LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaSemanticDeclId, LuaSignatureId, LuaType, LuaTypeDeclId, RenderLevel, SemanticDeclLevel, SemanticInfo, SemanticModel, }; +use glua_code_analysis::{humanize_member_key_name, humanize_type}; use glua_parser::{ LuaAssignStat, LuaAstNode, LuaCallArgList, LuaExpr, LuaFuncStat, LuaIndexExpr, LuaSyntaxKind, LuaSyntaxToken, LuaTableExpr, LuaTableField, LuaVarExpr, PathTrait, @@ -33,6 +33,7 @@ pub fn build_semantic_info_hover( token: LuaSyntaxToken, semantic_info: SemanticInfo, range: TextRange, + render_level: Option, ) -> Option { let typ = semantic_info.clone().typ; if semantic_info.semantic_decl.is_none() { @@ -46,6 +47,7 @@ pub fn build_semantic_info_hover( semantic_info.semantic_decl.unwrap(), false, Some(token.clone()), + render_level, ); if let Some(hover_builder) = hover_builder { hover_builder.build_hover_result(document.to_lsp_range(range)) @@ -60,6 +62,7 @@ pub fn build_assignment_target_hover( db: &DbIndex, document: &LuaDocument, token: LuaSyntaxToken, + render_level: Option, ) -> Option { let typ = get_assignment_target_type(&token, semantic_model)?; let range = token.text_range(); @@ -72,6 +75,7 @@ pub fn build_assignment_target_hover( semantic_decl, false, Some(token.clone()), + render_level, )?; return hover_builder.build_hover_result(document.to_lsp_range(range)); } @@ -98,13 +102,7 @@ fn build_hover_without_property( }); } - let render_level = db - .get_emmyrc() - .hover - .custom_detail - .map_or(RenderLevel::Detailed, |custom_detail| { - RenderLevel::CustomDetailed(custom_detail) - }); + let render_level = RenderLevel::Detailed; let hover = humanize_type(db, &typ, render_level); Some(Hover { @@ -133,6 +131,7 @@ fn build_dynamic_field_hover_without_property( if field_name.is_empty() { return None; } + let display_field_name = humanize_member_key_name(&field_name); let prefix_type = semantic_model .infer_expr(index_expr.get_prefix_expr()?) @@ -158,14 +157,14 @@ fn build_dynamic_field_hover_without_property( }; let type_humanize_text = if hover_type.is_const() { - hover_const_type(db, &hover_type) + hover_const_type(db, &hover_type, RenderLevel::Detailed) } else { humanize_type(db, &hover_type, RenderLevel::Simple) }; Some(format!( "```lua\n(infer) {}: {}\n```", - field_name, type_humanize_text + display_field_name, type_humanize_text )) } @@ -214,6 +213,7 @@ pub fn build_hover_content_for_completion<'a>( property_id, true, None, + None, ) } @@ -225,8 +225,18 @@ fn build_hover_content<'a>( property_id: LuaSemanticDeclId, is_completion: bool, token: Option, + render_level: Option, ) -> Option> { - let mut builder = HoverBuilder::new(compilation, semantic_model, token, is_completion); + let mut builder = match render_level { + Some(level) => HoverBuilder::new_with_level( + compilation, + semantic_model, + token, + is_completion, + Some(level), + ), + None => HoverBuilder::new(compilation, semantic_model, token, is_completion), + }; match property_id { LuaSemanticDeclId::LuaDecl(decl_id) => { let typ = typ?; @@ -283,7 +293,7 @@ fn build_decl_hover( .add_signature_params_rets_description(builder.semantic_model.get_type(decl_id.into())); } else { if typ.is_const() { - let const_value = hover_const_type(db, &typ); + let const_value = hover_const_type(db, &typ, builder.detail_render_level); let prefix = if decl.is_local() { "local " } else { @@ -366,7 +376,7 @@ fn build_member_hover( .any(|(_, semantic_typ)| is_function(semantic_typ)); let member_name = match member.get_key() { - LuaMemberKey::Name(name) => name.to_string(), + LuaMemberKey::Name(name) => humanize_member_key_name(name.as_str()), LuaMemberKey::Integer(i) => format!("[{}]", i), _ => return None, }; @@ -405,7 +415,7 @@ fn build_member_hover( } } else { if typ.is_const() { - let const_value = hover_const_type(db, &typ); + let const_value = hover_const_type(db, &typ, builder.detail_render_level); builder.set_type_description(format!("(field) {}: {}", member_name, const_value)); builder.set_location_path(Some(member)); } else { @@ -542,8 +552,8 @@ fn add_member_color_preview( 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(), + let member_name = match member.get_key() { + LuaMemberKey::Name(name) => humanize_member_key_name(name.as_str()), _ => return None, }; builder.set_type_description(format!("(field) {}: {}", member_name, color.gmod_display)); diff --git a/crates/glua_ls/src/handlers/hover/hover_builder.rs b/crates/glua_ls/src/handlers/hover/hover_builder.rs index 867e0683..1a7870fb 100644 --- a/crates/glua_ls/src/handlers/hover/hover_builder.rs +++ b/crates/glua_ls/src/handlers/hover/hover_builder.rs @@ -50,12 +50,19 @@ impl<'a> HoverBuilder<'a> { token: Option, is_completion: bool, ) -> Self { - let detail_render_level = - if let Some(custom_detail) = semantic_model.get_emmyrc().hover.custom_detail { - RenderLevel::CustomDetailed(custom_detail) - } else { - RenderLevel::Detailed - }; + Self::new_with_level(compilation, semantic_model, token, is_completion, None) + } + + /// Create a new `HoverBuilder` with an explicit render level override. + /// When `render_level` is `Some`, it overrides the default `Detailed` level. + pub fn new_with_level( + compilation: &'a LuaCompilation, + semantic_model: &'a SemanticModel, + token: Option, + is_completion: bool, + render_level: Option, + ) -> Self { + let detail_render_level = render_level.unwrap_or(RenderLevel::Detailed); let substitutor = if let Some(token) = token.clone() { infer_substitutor_base_type(semantic_model, token) diff --git a/crates/glua_ls/src/handlers/hover/hover_expand.rs b/crates/glua_ls/src/handlers/hover/hover_expand.rs new file mode 100644 index 00000000..ab41b500 --- /dev/null +++ b/crates/glua_ls/src/handlers/hover/hover_expand.rs @@ -0,0 +1,49 @@ +use crate::context::ServerContextSnapshot; + +use super::hover_expand_request::{ + HoverExpandParams, HoverExpandResponse, compute_max_level_at_position, level_to_display_count, +}; + +use glua_code_analysis::RenderLevel; +use tokio_util::sync::CancellationToken; + +pub async fn on_hover_expand_handler( + context: ServerContextSnapshot, + params: HoverExpandParams, + cancel_token: CancellationToken, +) -> Option { + if cancel_token.is_cancelled() { + return None; + } + + let uri = params.text_document.uri; + let position = params.position; + let level = params.level.unwrap_or(0); + + let analysis = context.read_analysis(&cancel_token).await?; + if cancel_token.is_cancelled() { + return None; + } + + let file_id = analysis.get_file_id(&uri)?; + let semantic_model = analysis.compilation.get_semantic_model(file_id)?; + if !semantic_model.get_emmyrc().hover.enable { + return None; + } + + // Compute max level for the symbol at this position. + let max_level = compute_max_level_at_position(&semantic_model, position); + + // Map verbosity level to a display count and create the render level. + let display_count = level_to_display_count(level); + let render_level = RenderLevel::DetailedCount(display_count); + + // Reuse the existing hover pipeline with a custom render level. + let hover = super::hover(&analysis, file_id, position, Some(render_level))?; + + Some(HoverExpandResponse { + content: hover.contents, + range: hover.range, + max_level, + }) +} diff --git a/crates/glua_ls/src/handlers/hover/hover_expand_request.rs b/crates/glua_ls/src/handlers/hover/hover_expand_request.rs new file mode 100644 index 00000000..1bdd75c7 --- /dev/null +++ b/crates/glua_ls/src/handlers/hover/hover_expand_request.rs @@ -0,0 +1,273 @@ +use lsp_types::request::Request; +use lsp_types::{Position, TextDocumentIdentifier}; +use serde::{Deserialize, Serialize}; + +use glua_code_analysis::{DEFAULT_DETAIL_MEMBER_DISPLAY_COUNT, LuaType, SemanticModel}; +use glua_parser::LuaAstNode; +use rowan::TokenAtOffset; + +// ── LSP request type ──────────────────────────────────────────────────────── + +#[derive(Debug)] +pub enum GluaHoverExpandRequest {} + +impl Request for GluaHoverExpandRequest { + type Params = HoverExpandParams; + type Result = Option; + const METHOD: &'static str = "gluals/hoverExpand"; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HoverExpandParams { + pub text_document: TextDocumentIdentifier, + pub position: Position, + /// Verbosity level. 0 = default compact member count, higher = more members. + pub level: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HoverExpandResponse { + /// Full hover markdown content. + pub content: lsp_types::HoverContents, + /// The range in the document this hover applies to. + pub range: Option, + /// Maximum verbosity level available for this symbol. + pub max_level: u32, +} + +// ── Verbosity level helpers ───────────────────────────────────────────────── + +/// Maps a verbosity level to the max number of class members to display. +pub fn level_to_display_count(level: u32) -> usize { + match level { + 0 => DEFAULT_DETAIL_MEMBER_DISPLAY_COUNT, // default — compact, fits in hover popup + 1 => 12, // more detail + 2 => 24, // verbose + 3 => 50, // very verbose + 4 => 100, // extremely verbose + _ => usize::MAX, // level 5+ - show everything + } +} + +/// Computes the maximum verbosity level for a given total member count. +/// +/// Returns 0 when all members fit at the default display count (no + button). +pub fn compute_max_level(total: usize) -> u32 { + (0..) + .find(|level| level_to_display_count(*level) >= total) + .unwrap_or(0) +} + +fn compute_max_level_for_type(semantic_model: &SemanticModel, typ: &LuaType) -> u32 { + let total = match typ { + LuaType::Def(_) + | LuaType::Ref(_) + | LuaType::TableConst(_) + | LuaType::MergedTable(_) + | LuaType::Instance(_) => semantic_model + .get_member_infos(typ) + .map(|members| members.len()) + .unwrap_or(0), + LuaType::Object(object) => object.get_fields().len(), + _ => 0, + }; + compute_max_level(total) +} + +/// Computes the maximum verbosity level for the type at the given position. +/// +/// Returns non-zero for rendered table-like hovers that can show more members. +pub fn compute_max_level_at_position(semantic_model: &SemanticModel, position: Position) -> u32 { + let document = semantic_model.get_document(); + let Some(offset) = document.get_offset(position.line as usize, position.character as usize) + else { + return 0; + }; + let root = semantic_model.get_root(); + let token = match root.syntax().token_at_offset(offset) { + TokenAtOffset::Single(t) => t, + TokenAtOffset::Between(l, _) => l, + TokenAtOffset::None => return 0, + }; + + let semantic_info = semantic_model.get_semantic_info(token.into()); + let typ = match semantic_info { + Some(info) => info.typ, + None => return 0, + }; + + compute_max_level_for_type(semantic_model, &typ) +} + +#[cfg(test)] +mod tests { + use crate::handlers::test_lib::check; + + use glua_code_analysis::{LuaType, LuaTypeDeclId, RenderLevel, VirtualWorkspace}; + use googletest::prelude::*; + use lsp_types::HoverContents; + use rowan::TextSize; + + use super::{ + compute_max_level, compute_max_level_at_position, compute_max_level_for_type, + level_to_display_count, + }; + + #[gtest] + fn max_level_display_count_covers_large_member_count() { + let total = 501; + let max_level = compute_max_level(total); + + assert_that!(level_to_display_count(max_level), ge(total)); + } + + #[gtest] + fn default_level_display_count_is_compact() { + assert_that!(level_to_display_count(0), eq(6)); + assert_that!(compute_max_level(6), eq(0)); + assert_that!(compute_max_level(7), eq(1)); + } + + #[gtest] + fn def_type_hover_reports_expandable_max_level() -> Result<()> { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def_file( + "lua/entities/base_glide.lua", + r#" + ---@class base_glide + ---@field crosshair table? + ---@field _playerListExpanded any + ---@field weaponSwitchNotification table? + ---@field _expandTimer number + ---@field AutomaticFrameAdvance boolean + ---@field _hasBothSpriteBeams boolean? + ---@field EnableCrosshair function + "#, + ); + let semantic_model = check!(ws.analysis.compilation.get_semantic_model(file_id)); + + assert_that!( + compute_max_level_for_type( + &semantic_model, + &LuaType::Def(LuaTypeDeclId::global("base_glide")), + ), + eq(1) + ); + Ok(()) + } + + #[gtest] + fn table_const_field_hover_reports_expandable_max_level() -> Result<()> { + let mut ws = VirtualWorkspace::new(); + let content = r#" + local ENT = {} + ENT.SuspensionPoseParameters = { + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + { parameter = "vehicle_wheel_fr_height", wheel = 2 }, + { parameter = "vehicle_wheel_rl_height", wheel = 3 }, + { parameter = "vehicle_wheel_rr_height", wheel = 4 }, + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + { parameter = "vehicle_wheel_fr_height", wheel = 2 }, + { parameter = "vehicle_wheel_rl_height", wheel = 3 }, + { parameter = "vehicle_wheel_rr_height", wheel = 4 }, + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + { parameter = "vehicle_wheel_fr_height", wheel = 2 }, + { parameter = "vehicle_wheel_rl_height", wheel = 3 }, + { parameter = "vehicle_wheel_rr_height", wheel = 4 }, + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + } + "#; + let file_id = ws.def_file("lua/entities/fl_audi_r8.lua", content); + let semantic_model = check!(ws.analysis.compilation.get_semantic_model(file_id)); + let document = semantic_model.get_document(); + let name_offset = check!(content.find("SuspensionPoseParameters")); + let position = check!(document.to_lsp_position(TextSize::new(name_offset as u32))); + + assert_that!( + compute_max_level_at_position(&semantic_model, position), + eq(2) + ); + Ok(()) + } + + #[gtest] + fn default_table_const_field_hover_renders_six_rows() -> Result<()> { + let mut ws = VirtualWorkspace::new(); + let content = r#" + local ENT = {} + ENT.SuspensionPoseParameters = { + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + { parameter = "vehicle_wheel_fr_height", wheel = 2 }, + { parameter = "vehicle_wheel_rl_height", wheel = 3 }, + { parameter = "vehicle_wheel_rr_height", wheel = 4 }, + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + { parameter = "vehicle_wheel_fr_height", wheel = 2 }, + { parameter = "vehicle_wheel_rl_height", wheel = 3 }, + } + "#; + let file_id = ws.def_file("lua/entities/fl_audi_r8.lua", content); + let semantic_model = check!(ws.analysis.compilation.get_semantic_model(file_id)); + let document = semantic_model.get_document(); + let name_offset = check!(content.find("SuspensionPoseParameters")); + let position = check!(document.to_lsp_position(TextSize::new(name_offset as u32))); + + let hover = check!(crate::handlers::hover::hover( + &ws.analysis, + file_id, + position, + None, + )); + let HoverContents::Markup(markup) = hover.contents else { + return fail!("expected HoverContents::Markup"); + }; + + verify_that!(markup.value.as_str(), contains_substring("[6]"))?; + verify_that!(markup.value.as_str(), not(contains_substring("[7]")))?; + verify_that!(markup.value.as_str(), contains_substring(" ..."))?; + Ok(()) + } + + #[gtest] + fn expanded_table_const_field_hover_renders_more_rows() -> Result<()> { + let mut ws = VirtualWorkspace::new(); + let content = r#" + local ENT = {} + ENT.SuspensionPoseParameters = { + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + { parameter = "vehicle_wheel_fr_height", wheel = 2 }, + { parameter = "vehicle_wheel_rl_height", wheel = 3 }, + { parameter = "vehicle_wheel_rr_height", wheel = 4 }, + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + { parameter = "vehicle_wheel_fr_height", wheel = 2 }, + { parameter = "vehicle_wheel_rl_height", wheel = 3 }, + { parameter = "vehicle_wheel_rr_height", wheel = 4 }, + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + { parameter = "vehicle_wheel_fr_height", wheel = 2 }, + { parameter = "vehicle_wheel_rl_height", wheel = 3 }, + { parameter = "vehicle_wheel_rr_height", wheel = 4 }, + { parameter = "vehicle_wheel_fl_height", wheel = 1 }, + } + "#; + let file_id = ws.def_file("lua/entities/fl_audi_r8.lua", content); + let semantic_model = check!(ws.analysis.compilation.get_semantic_model(file_id)); + let document = semantic_model.get_document(); + let name_offset = check!(content.find("SuspensionPoseParameters")); + let position = check!(document.to_lsp_position(TextSize::new(name_offset as u32))); + + let hover = check!(crate::handlers::hover::hover( + &ws.analysis, + file_id, + position, + Some(RenderLevel::DetailedCount(24)), + )); + let HoverContents::Markup(markup) = hover.contents else { + return fail!("expected HoverContents::Markup"); + }; + + verify_that!(markup.value.as_str(), contains_substring("[13]"))?; + verify_that!(markup.value.as_str(), not(contains_substring(" ...")))?; + Ok(()) + } +} diff --git a/crates/glua_ls/src/handlers/hover/humanize_types.rs b/crates/glua_ls/src/handlers/hover/humanize_types.rs index cfd552f7..4765ba2e 100644 --- a/crates/glua_ls/src/handlers/hover/humanize_types.rs +++ b/crates/glua_ls/src/handlers/hover/humanize_types.rs @@ -14,8 +14,8 @@ use rowan::{TextRange, TextSize}; use super::hover_builder::HoverBuilder; -pub fn hover_const_type(db: &DbIndex, typ: &LuaType) -> String { - let const_value = humanize_type(db, typ, RenderLevel::Detailed); +pub fn hover_const_type(db: &DbIndex, typ: &LuaType, render_level: RenderLevel) -> String { + let const_value = humanize_type(db, typ, render_level); match typ { LuaType::IntegerConst(_) | LuaType::DocIntegerConst(_) => { diff --git a/crates/glua_ls/src/handlers/hover/mod.rs b/crates/glua_ls/src/handlers/hover/mod.rs index dbe0edb8..912556dc 100644 --- a/crates/glua_ls/src/handlers/hover/mod.rs +++ b/crates/glua_ls/src/handlers/hover/mod.rs @@ -3,6 +3,8 @@ mod color_swatch; mod find_origin; mod function; mod hover_builder; +pub mod hover_expand; +pub mod hover_expand_request; mod humanize_type_decl; mod humanize_types; mod keyword_hover; @@ -53,10 +55,15 @@ pub async fn on_hover( return None; } let file_id = analysis.get_file_id(&uri)?; - hover(&analysis, file_id, position) + hover(&analysis, file_id, position, None) } -pub fn hover(analysis: &EmmyLuaAnalysis, file_id: FileId, position: Position) -> Option { +pub fn hover( + analysis: &EmmyLuaAnalysis, + file_id: FileId, + position: Position, + render_level: Option, +) -> Option { let semantic_model = analysis.compilation.get_semantic_model(file_id)?; if !semantic_model.get_emmyrc().hover.enable { return None; @@ -149,6 +156,7 @@ pub fn hover(analysis: &EmmyLuaAnalysis, file_id: FileId, position: Position) -> detail, semantic_info, path.last()?.1, + render_level, ) } doc_see if doc_see.kind() == LuaTokenKind::TkDocSeeContent.into() => { @@ -168,6 +176,7 @@ pub fn hover(analysis: &EmmyLuaAnalysis, file_id: FileId, position: Position) -> doc_see, semantic_info, path.last()?.1, + render_level, ) } _ => { @@ -196,6 +205,7 @@ pub fn hover(analysis: &EmmyLuaAnalysis, file_id: FileId, position: Position) -> db, &document, token, + render_level, ); }; let range = token.text_range(); @@ -208,6 +218,7 @@ pub fn hover(analysis: &EmmyLuaAnalysis, file_id: FileId, position: Position) -> token.clone(), semantic_info, range, + render_level, ) .or_else(|| { build_assignment_target_hover( @@ -216,6 +227,7 @@ pub fn hover(analysis: &EmmyLuaAnalysis, file_id: FileId, position: Position) -> db, &document, token, + render_level, ) }) } diff --git a/crates/glua_ls/src/handlers/initialized/mod.rs b/crates/glua_ls/src/handlers/initialized/mod.rs index aaf95b45..36e05f4a 100644 --- a/crates/glua_ls/src/handlers/initialized/mod.rs +++ b/crates/glua_ls/src/handlers/initialized/mod.rs @@ -19,6 +19,7 @@ use crate::{ initialized::std_i18n::try_generate_translated_std, text_document::register_files_watch, }, logger::init_logger, + util::{LongRunningWatchdogStatus, spawn_long_running_watchdog}, }; pub use client_config::{ClientConfig, get_client_config}; use codestyle::load_editorconfig; @@ -34,6 +35,7 @@ pub async fn initialized_handler( params: InitializeParams, cmd_args: CmdArgs, ) -> Option<()> { + log::info!("initialized handler started"); // init locale locale::set_ls_locale(¶ms); let workspace_folders = get_workspace_folders(¶ms); @@ -45,6 +47,8 @@ pub async fn initialized_handler( // init logger init_logger(main_root, &cmd_args); log::info!("main root: {:?}", main_root); + let watchdog_status = LongRunningWatchdogStatus::new("reading client capabilities"); + let _watchdog = spawn_long_running_watchdog("language server startup", watchdog_status.clone()); let client_id = if let Some(editor) = &cmd_args.editor { editor.clone().into() @@ -121,6 +125,8 @@ pub async fn initialized_handler( log::info!("initialization_params: {}", params_json); // init config + watchdog_status.set_phase("Loading GLuaLS configuration"); + log::info!("loading GLuaLS configuration"); let config_roots = workspace_folders .iter() .map(|workspace| workspace.root.clone()) @@ -131,11 +137,17 @@ pub async fn initialized_handler( let workspace_emmyrcs = loaded.workspace_emmyrcs; let workspace_matchers = loaded.workspace_matchers; load_editorconfig(workspace_folders.clone(), emmyrc.as_ref()); + log::info!("configuration loaded"); // init std lib + watchdog_status.set_phase("Loading standard libraries"); + log::info!("loading standard libraries"); init_std_lib(context.analysis(), &cmd_args, emmyrc.clone()).await; + log::info!("standard libraries loaded"); { + watchdog_status.set_phase("Preparing workspace manager"); + log::info!("preparing workspace manager"); let mut workspace_manager = context.workspace_manager().write().await; workspace_manager.client_config = client_config.clone(); let (include, exclude, exclude_dir) = calculate_include_and_exclude(&emmyrc); @@ -154,10 +166,13 @@ pub async fn initialized_handler( emmyrc.clone(), workspace_diagnostic_configs, workspace_emmyrcs, + watchdog_status.clone(), ) .await; register_files_watch(context.clone(), ¶ms.capabilities).await; + log::info!("initialized handler completed; notifying workspace loaded"); + context.file_diagnostic().notify_workspace_loaded(); Some(()) } @@ -170,6 +185,7 @@ pub async fn init_analysis( emmyrc: Arc, workspace_diagnostic_configs: HashMap, workspace_emmyrcs: HashMap>, + watchdog_status: LongRunningWatchdogStatus, ) { if let Ok(emmyrc_json) = serde_json::to_string_pretty(emmyrc.as_ref()) { log::info!("current config : {}", emmyrc_json); @@ -181,8 +197,10 @@ pub async fn init_analysis( status_bar.update_progress_task( ProgressTask::LoadWorkspace, None, - Some("Loading workspace files".to_string()), + Some("Loading folders".to_string()), ); + watchdog_status.set_phase("Preparing workspace folders"); + log::info!("preparing workspace folders for initial indexing"); let workspace_roots = workspace_folders .into_iter() @@ -211,8 +229,10 @@ pub async fn init_analysis( status_bar.update_progress_task( ProgressTask::LoadWorkspace, None, - Some(String::from("Collecting files")), + Some(String::from("Scanning Lua files")), ); + watchdog_status.set_phase("Collecting Lua files"); + log::info!("collecting workspace files for initial indexing"); // load files with per-workspace configs let mut files = Vec::new(); @@ -247,10 +267,21 @@ pub async fn init_analysis( None, Some(format!("Indexing {} files", file_count)), ); + watchdog_status.set_progress("Indexing Lua files", 0, file_count); + log::info!("indexing {} Lua files", file_count); + } else { + log::info!("no Lua files found during initial indexing"); } // Hold the write lock only for analysis state mutations. let mut mut_analysis = analysis.write().await; + status_bar.update_progress_task( + ProgressTask::LoadWorkspace, + None, + Some(String::from("Applying config")), + ); + watchdog_status.set_phase("Applying workspace configuration"); + log::info!("applying workspace configuration to analysis"); // update config mut_analysis.update_config(emmyrc.clone()); @@ -290,7 +321,15 @@ pub async fn init_analysis( } if file_count != 0 { + status_bar.update_progress_task( + ProgressTask::LoadWorkspace, + None, + Some(format!("Analyzing {} files", file_count)), + ); + watchdog_status.set_progress("Analyzing Lua files", 0, file_count); + log::info!("analyzing {} Lua files", file_count); mut_analysis.update_files_by_path(files); + watchdog_status.set_progress("Analyzing Lua files", file_count, file_count); } let schema_urls = if mut_analysis.check_schema_update() { @@ -305,24 +344,40 @@ pub async fn init_analysis( status_bar.update_progress_task( ProgressTask::LoadWorkspace, None, - Some(String::from("Finished loading workspace files")), - ); - status_bar.finish_progress_task( - ProgressTask::LoadWorkspace, - Some("Indexing complete".to_string()), + Some(String::from("Workspace ready")), ); + watchdog_status.set_phase("Workspace index ready"); + log::info!("workspace index ready"); if !schema_urls.is_empty() { + status_bar.update_progress_task( + ProgressTask::LoadWorkspace, + None, + Some(format!("Fetching {} schemas", schema_urls.len())), + ); + watchdog_status.set_progress("Fetching JSON schemas", 0, schema_urls.len()); + log::info!("fetching {} JSON schemas", schema_urls.len()); let url_contents = fetch_schema_urls(schema_urls).await; let mut mut_analysis = analysis.write().await; mut_analysis.apply_fetched_schemas(url_contents); file_diagnostic.invalidate_shared_diagnostic_data(); + watchdog_status.set_phase("JSON schema fetch/apply complete"); + log::info!("JSON schema fetch/apply complete"); } + status_bar.finish_progress_task( + ProgressTask::LoadWorkspace, + Some("Workspace ready".to_string()), + ); + file_diagnostic.notify_workspace_loaded(); + if !lsp_features.supports_workspace_diagnostic() { + log::info!("client does not support workspace diagnostics; scheduling push diagnostics"); file_diagnostic .add_workspace_diagnostic_task(0, false) .await; + } else { + log::info!("client supports workspace diagnostics; waiting for diagnostic pull requests"); } } diff --git a/crates/glua_ls/src/handlers/inlay_hint/build_function_hint.rs b/crates/glua_ls/src/handlers/inlay_hint/build_function_hint.rs index 32d53b68..ed8e77a0 100644 --- a/crates/glua_ls/src/handlers/inlay_hint/build_function_hint.rs +++ b/crates/glua_ls/src/handlers/inlay_hint/build_function_hint.rs @@ -1,10 +1,8 @@ -use std::collections::HashMap; - use glua_code_analysis::{ - LuaSignatureId, LuaType, LuaUnionType, RenderLevel, SemanticModel, format_union_type, - humanize_type, + LuaDeclId, LuaType, LuaUnionType, RenderLevel, SemanticModel, format_union_type, humanize_type, + infer_param_with_cache, }; -use glua_parser::{LuaAstNode, LuaClosureExpr}; +use glua_parser::{LuaAstNode, LuaAstToken, LuaClosureExpr, LuaParamName}; use itertools::Itertools; use lsp_types::{InlayHint, InlayHintKind, InlayHintLabel, InlayHintLabelPart, Location}; @@ -16,69 +14,67 @@ pub fn build_closure_hint( if !semantic_model.get_emmyrc().hint.param_hint { return Some(()); } - let file_id = semantic_model.get_file_id(); - let signature_id = LuaSignatureId::from_closure(file_id, &closure); - let signature = semantic_model - .get_db() - .get_signature_index() - .get(&signature_id)?; - let lua_params = closure.get_params_list()?; - let signature_params = signature.get_type_params(); - let mut lua_params_map = HashMap::new(); - for param in lua_params.get_params() { - if let Some(name_token) = param.get_name_token() { - let name = name_token.get_name_text().to_string(); - lua_params_map.insert(name, param); - } else if param.is_dots() { - lua_params_map.insert("...".to_string(), param); - } - } - let document = semantic_model.get_document(); - for (signature_param_name, typ) in &signature_params { - if let Some(typ) = typ { - if typ.is_any() { - continue; - } + for lua_param in lua_params.get_params() { + let typ = infer_lua_param_hint_type(semantic_model, &lua_param)?; + if typ.is_any() || typ.is_unknown() { + continue; + } - if let Some(lua_param) = lua_params_map.get(signature_param_name) { - let lsp_range = document.to_lsp_range(lua_param.get_range())?; - // 构造 label - let mut label_parts = build_label_parts(semantic_model, typ); - // 为空时添加默认值 - if label_parts.is_empty() { - let typ_desc = format!( - ": {}", - hint_humanize_type(semantic_model, typ, RenderLevel::Simple) - ); - label_parts.push(InlayHintLabelPart { - value: typ_desc, - location: Some( - get_type_location(semantic_model, typ, 0) - .unwrap_or(Location::new(document.get_uri(), lsp_range)), - ), - ..Default::default() - }); - } - let hint = InlayHint { - kind: Some(InlayHintKind::TYPE), - label: InlayHintLabel::LabelParts(label_parts), - position: lsp_range.end, - text_edits: None, - tooltip: None, - padding_left: Some(true), - padding_right: None, - data: None, - }; - result.push(hint); - } + let lsp_range = document.to_lsp_range(lua_param.get_range())?; + let mut label_parts = build_label_parts(semantic_model, &typ); + if label_parts.is_empty() { + let typ_desc = format!( + ": {}", + hint_humanize_type(semantic_model, &typ, RenderLevel::Simple) + ); + label_parts.push(InlayHintLabelPart { + value: typ_desc, + location: Some( + get_type_location(semantic_model, &typ, 0) + .unwrap_or(Location::new(document.get_uri(), lsp_range)), + ), + ..Default::default() + }); } + let hint = InlayHint { + kind: Some(InlayHintKind::TYPE), + label: InlayHintLabel::LabelParts(label_parts), + position: lsp_range.end, + text_edits: None, + tooltip: None, + padding_left: Some(true), + padding_right: None, + data: None, + }; + result.push(hint); } Some(()) } +fn infer_lua_param_hint_type( + semantic_model: &SemanticModel, + lua_param: &LuaParamName, +) -> Option { + let token = lua_param + .get_name_token() + .map(|token| token.syntax().clone()) + .or_else(|| lua_param.syntax().first_token())?; + let decl_id = LuaDeclId::new(semantic_model.get_file_id(), token.text_range().start()); + let decl = semantic_model + .get_db() + .get_decl_index() + .get_decl(&decl_id)?; + infer_param_with_cache( + semantic_model.get_db(), + &mut semantic_model.get_cache().borrow_mut(), + decl, + ) + .ok() +} + pub fn build_label_parts(semantic_model: &SemanticModel, typ: &LuaType) -> Vec { let mut parts: Vec = Vec::new(); match typ { diff --git a/crates/glua_ls/src/handlers/request_handler.rs b/crates/glua_ls/src/handlers/request_handler.rs index 18d82780..b966ec29 100644 --- a/crates/glua_ls/src/handlers/request_handler.rs +++ b/crates/glua_ls/src/handlers/request_handler.rs @@ -53,6 +53,8 @@ use super::{ GmodScriptedClassesRequest, GmodScriptedClassesV2Request, on_gmod_scripted_classes_handler, on_gmod_scripted_classes_v2_handler, }, + hover::hover_expand::on_hover_expand_handler, + hover::hover_expand_request::GluaHoverExpandRequest, hover::on_hover, implementation::on_implementation_handler, inlay_hint::{on_inlay_hint_handler, on_resolve_inlay_hint}, @@ -184,6 +186,7 @@ pub async fn on_request_handler( InlineValueRequest => on_inline_values_handler, WorkspaceSymbolRequest => on_workspace_symbol_handler, GluaDocSearchRequest => on_doc_search_handler, + GluaHoverExpandRequest => on_hover_expand_handler, GmodScriptedClassesRequest => on_gmod_scripted_classes_handler, GmodScriptedClassesV2Request => on_gmod_scripted_classes_v2_handler, InlayHintRequest => on_inlay_hint_handler, diff --git a/crates/glua_ls/src/handlers/semantic_token/build_semantic_tokens.rs b/crates/glua_ls/src/handlers/semantic_token/build_semantic_tokens.rs index 01f9ca4b..d70cfc4e 100644 --- a/crates/glua_ls/src/handlers/semantic_token/build_semantic_tokens.rs +++ b/crates/glua_ls/src/handlers/semantic_token/build_semantic_tokens.rs @@ -1,6 +1,7 @@ use super::{ SEMANTIC_TOKEN_MODIFIERS, SEMANTIC_TOKEN_TYPES, semantic_token_builder::SemanticBuilder, }; +use crate::handlers::semantic_token::escape_sequence_highlight::highlight_string_escapes; use crate::handlers::semantic_token::function_string_highlight::fun_string_highlight; use crate::handlers::semantic_token::semantic_token_builder::{ CustomSemanticTokenModifier, CustomSemanticTokenType, @@ -69,12 +70,67 @@ fn build_tokens_semantic_token( client_id: ClientId, emmyrc: &Emmyrc, ) { + if matches!( + token.kind(), + LuaKind::Token(LuaTokenKind::TkName | LuaTokenKind::TkBreak) + ) { + let prev_kind = token.prev_token().map(|prev| prev.kind()); + let next_kind = token.next_token().map(|next| next.kind()); + if prev_kind == Some(LuaKind::Token(LuaTokenKind::TkColon)) + && next_kind == Some(LuaKind::Token(LuaTokenKind::TkColon)) + { + builder.push_with_modifier_force( + token, + CustomSemanticTokenType::LABEL, + SemanticTokenModifier::DECLARATION, + ); + return; + } + + if prev_kind == Some(LuaKind::Token(LuaTokenKind::TkGoto)) { + builder.push_force(token, CustomSemanticTokenType::LABEL); + return; + } + + let label_context = token.parent().and_then(|parent| { + parent + .ancestors() + .find(|node| { + node.kind() == LuaSyntaxKind::LabelStat.into() + || node.kind() == LuaSyntaxKind::GotoStat.into() + }) + .map(|node| node.kind()) + }); + + if label_context == Some(LuaSyntaxKind::LabelStat.into()) { + builder.push_with_modifier_force( + token, + CustomSemanticTokenType::LABEL, + SemanticTokenModifier::DECLARATION, + ); + return; + } + + if label_context == Some(LuaSyntaxKind::GotoStat.into()) { + builder.push_force(token, CustomSemanticTokenType::LABEL); + return; + } + } + match token.kind().into() { - LuaTokenKind::TkLongString | LuaTokenKind::TkString => { + LuaTokenKind::TkLongString => { + // Long strings do not process escape sequences, so keep a single STRING token. if !builder.is_special_string_range(&token.text_range()) { builder.push(token, SemanticTokenType::STRING); } } + LuaTokenKind::TkString => { + // Regular strings: split out escape sequences as separate tokens so editors + // can color them distinctly. + if !builder.is_special_string_range(&token.text_range()) { + highlight_string_escapes(builder, token); + } + } LuaTokenKind::TkAnd | LuaTokenKind::TkBreak | LuaTokenKind::TkDo @@ -390,45 +446,65 @@ fn build_node_semantic_token( } LuaAst::LuaDocTagField(doc_field) => { if let Some(LuaDocFieldKey::Name(name)) = doc_field.get_field_key() { - builder.push_with_modifier( + builder.push_with_modifiers( name.syntax(), - SemanticTokenType::PROPERTY, - SemanticTokenModifier::DECLARATION, + CustomSemanticTokenType::FIELD, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DOCUMENTATION, + ], ); } } LuaAst::LuaDocTagDiagnostic(doc_diagnostic) => { let name = doc_diagnostic.get_action_token()?; - builder.push(name.syntax(), SemanticTokenType::PROPERTY); + builder.push_with_modifier( + name.syntax(), + SemanticTokenType::PROPERTY, + SemanticTokenModifier::DOCUMENTATION, + ); if let Some(code_list) = doc_diagnostic.get_code_list() { for code in code_list.get_codes() { - builder.push(code.syntax(), SemanticTokenType::REGEXP); + builder.push_with_modifier( + code.syntax(), + SemanticTokenType::REGEXP, + SemanticTokenModifier::DOCUMENTATION, + ); } } } LuaAst::LuaDocTagParam(doc_param) => { let name = doc_param.get_name_token()?; - builder.push_with_modifier( + builder.push_with_modifiers( name.syntax(), SemanticTokenType::PARAMETER, - SemanticTokenModifier::DECLARATION, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DOCUMENTATION, + ], ); } LuaAst::LuaDocTagRealm(doc_realm) => { if let Some(realm) = doc_realm.get_name_token() { - builder.push_with_modifier( + builder.push_with_modifiers( realm.syntax(), SemanticTokenType::ENUM_MEMBER, - SemanticTokenModifier::DECLARATION, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DOCUMENTATION, + ], ); } } LuaAst::LuaDocTagFileparam(doc_fileparam) => { if let Some(name) = doc_fileparam.get_name_token() { - builder.push_with_modifier( + builder.push_with_modifiers( name.syntax(), SemanticTokenType::PARAMETER, - SemanticTokenModifier::DECLARATION, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DOCUMENTATION, + ], ); } } @@ -436,7 +512,11 @@ fn build_node_semantic_token( let type_name_list = doc_return.get_info_list(); for (_, name) in type_name_list { if let Some(name) = name { - builder.push(name.syntax(), SemanticTokenType::VARIABLE); + builder.push_with_modifier( + name.syntax(), + SemanticTokenType::VARIABLE, + SemanticTokenModifier::DOCUMENTATION, + ); } } } @@ -481,22 +561,32 @@ fn build_node_semantic_token( } LuaAst::LuaDocTagNamespace(doc_namespace) => { let name = doc_namespace.get_name_token()?; - builder.push_with_modifier( + builder.push_with_modifiers( name.syntax(), SemanticTokenType::NAMESPACE, - SemanticTokenModifier::DECLARATION, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DOCUMENTATION, + ], ); } LuaAst::LuaDocTagUsing(doc_using) => { let name = doc_using.get_name_token()?; - builder.push(name.syntax(), SemanticTokenType::NAMESPACE); + builder.push_with_modifier( + name.syntax(), + SemanticTokenType::NAMESPACE, + SemanticTokenModifier::DOCUMENTATION, + ); } LuaAst::LuaDocTagExport(doc_export) => { let name = doc_export.get_name_token()?; - builder.push_with_modifier( + builder.push_with_modifiers( name.syntax(), SemanticTokenType::NAMESPACE, - SemanticTokenModifier::MODIFICATION, + &[ + SemanticTokenModifier::MODIFICATION, + SemanticTokenModifier::DOCUMENTATION, + ], ); } LuaAst::LuaParamName(param_name) => { @@ -504,29 +594,57 @@ fn build_node_semantic_token( if builder.contains_token(name_token.syntax()) { return Some(()); } - handle_name_node(semantic_model, builder, param_name.syntax(), &name_token); + handle_name_node( + semantic_model, + builder, + param_name.syntax(), + &name_token, + false, + ); } LuaAst::LuaLocalName(local_name) => { let name_token = local_name.get_name_token()?; if builder.contains_token(name_token.syntax()) { return Some(()); } - handle_name_node(semantic_model, builder, local_name.syntax(), &name_token); + handle_name_node( + semantic_model, + builder, + local_name.syntax(), + &name_token, + false, + ) + .or_else(|| { + builder.push_with_modifiers( + name_token.syntax(), + SemanticTokenType::VARIABLE, + &[ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::LOCAL, + ], + ) + }); } LuaAst::LuaNameExpr(name_expr) => { let name_token = name_expr.get_name_token()?; if builder.contains_token(name_token.syntax()) { return Some(()); } - handle_name_node(semantic_model, builder, name_expr.syntax(), &name_token) - .unwrap_or_else(|| { - // 改进:为未知名称提供更好的默认分类 - let name_text = name_token.get_name_text(); - builder.push( - name_token.syntax(), - default_identifier_token_type(name_text), - ); - }); + handle_name_node( + semantic_model, + builder, + name_expr.syntax(), + &name_token, + true, + ) + .unwrap_or_else(|| { + // 改进:为未知名称提供更好的默认分类 + let name_text = name_token.get_name_text(); + builder.push( + name_token.syntax(), + default_identifier_token_type(name_text), + ); + }); } LuaAst::LuaForRangeStat(for_range_stat) => { for name in for_range_stat.get_var_name_list() { @@ -642,6 +760,18 @@ fn build_node_semantic_token( let name = local_attribute.get_name_token()?; builder.push(name.syntax(), SemanticTokenType::KEYWORD); } + LuaAst::LuaLabelStat(label_stat) => { + let name = label_stat.get_label_name_token()?; + builder.push_with_modifier_force( + name.syntax(), + CustomSemanticTokenType::LABEL, + SemanticTokenModifier::DECLARATION, + ); + } + LuaAst::LuaGotoStat(goto_stat) => { + let name = goto_stat.get_label_name_token()?; + builder.push_force(name.syntax(), CustomSemanticTokenType::LABEL); + } LuaAst::LuaCallExpr(call_expr) => { let prefix = call_expr.get_prefix_expr()?; match prefix { @@ -665,11 +795,8 @@ fn build_node_semantic_token( if builder.contains_token(name.syntax()) { return Some(()); } - builder.push_with_modifier( - name.syntax(), - SemanticTokenType::METHOD, - CustomSemanticTokenModifier::CALLABLE, - ); + // Let the IndexExpr branch classify this from semantic data. + // Dot calls can target real methods, function-valued fields, or unresolved fields. } _ => {} } @@ -703,7 +830,14 @@ fn build_node_semantic_token( if let Some(field_key) = field.get_field_key() && let LuaDocObjectFieldKey::Name(name) = &field_key { - builder.push(name.syntax(), CustomSemanticTokenType::FIELD); + builder.push_with_modifiers( + name.syntax(), + CustomSemanticTokenType::FIELD, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DOCUMENTATION, + ], + ); } } } @@ -757,8 +891,22 @@ fn build_node_semantic_token( && let LuaSemanticDeclId::Member(member_id) = property_owner { let decl_type = semantic_model.get_type(member_id.into()); - if decl_type.is_function() { + if is_function_like_type(&decl_type) { let mut modifiers = vec![]; + let mut token_type = CustomSemanticTokenType::FIELD; + if let Some(member) = semantic_model + .get_db() + .get_member_index() + .get_member(&member_id) + { + if is_method_like_member(semantic_model, member) + || is_default_library_type(semantic_model, &decl_type) + || index_prefix_is_namespace_like(semantic_model, &index_expr) + { + token_type = SemanticTokenType::METHOD; + } + } + modifiers.push(CustomSemanticTokenModifier::CALLABLE); enrich_modifiers_from_decl( semantic_model, &property_owner, @@ -769,7 +917,7 @@ fn build_node_semantic_token( builder, name.syntax(), index_expr.syntax(), - SemanticTokenType::METHOD, + token_type, &modifiers, ); return Some(()); @@ -804,7 +952,8 @@ fn build_node_semantic_token( return Some(()); } - let mut is_class_like = is_table_like_type(&decl_type); + let is_class_like = is_table_like_type(&decl_type); + let mut is_namespace_like = false; if !is_class_like && global_depth == Some(1) { if let Some(member) = semantic_model @@ -813,7 +962,7 @@ fn build_node_semantic_token( .get_member(&member_id) { if let Some(global_id) = member.get_global_id() { - is_class_like = global_path_has_class_like_children( + is_namespace_like = global_path_has_class_like_children( semantic_model, builder, global_id, @@ -822,28 +971,36 @@ fn build_node_semantic_token( } } - if is_class_like { - // Only highlight as CLASS if it is the first segment (depth == 1) of a global path. - // This prevents local table fields or deeper segments from becoming CLASS. - if global_depth == Some(1) { - push_name_or_syntax_with_context_modifiers( - builder, - name.syntax(), - index_expr.syntax(), - SemanticTokenType::CLASS, - &[], - ); - return Some(()); - } + if is_class_like && global_depth == Some(1) { + push_name_or_syntax_with_context_modifiers( + builder, + name.syntax(), + index_expr.syntax(), + SemanticTokenType::CLASS, + &[], + ); + return Some(()); + } + + if is_namespace_like && global_depth == Some(1) { + push_name_or_syntax_with_context_modifiers( + builder, + name.syntax(), + index_expr.syntax(), + SemanticTokenType::NAMESPACE, + &[], + ); + return Some(()); } if is_function_like_type(&decl_type) { + let modifiers = vec![CustomSemanticTokenModifier::CALLABLE]; push_name_or_syntax_with_context_modifiers( builder, name.syntax(), index_expr.syntax(), - SemanticTokenType::PROPERTY, - &[CustomSemanticTokenModifier::CALLABLE], + CustomSemanticTokenType::FIELD, + &modifiers, ); } else { push_name_or_syntax_with_context_modifiers( @@ -863,12 +1020,19 @@ fn build_node_semantic_token( .parent() .is_some_and(|p| p.kind() == LuaSyntaxKind::CallExpr.into()) { + let namespace_like_prefix = + index_prefix_is_namespace_like(semantic_model, &index_expr); + let modifiers = vec![CustomSemanticTokenModifier::CALLABLE]; push_name_or_syntax_with_context_modifiers( builder, name.syntax(), index_expr.syntax(), - SemanticTokenType::METHOD, - &[CustomSemanticTokenModifier::CALLABLE], + if namespace_like_prefix { + SemanticTokenType::METHOD + } else { + CustomSemanticTokenType::FIELD + }, + &modifiers, ); } else { push_name_or_syntax_with_context_modifiers( @@ -916,7 +1080,10 @@ fn build_node_semantic_token( match value_type { LuaType::Signature(_) | LuaType::DocFunction(_) => { if let Some(field_name) = table_field.get_field_key()?.get_name() { - let mut modifiers = vec![SemanticTokenModifier::DECLARATION]; + let mut modifiers = vec![ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::CALLABLE, + ]; if let Some(member) = semantic_model .get_db() .get_member_index() @@ -931,29 +1098,30 @@ fn build_node_semantic_token( } builder.push_with_modifiers( field_name.syntax(), - SemanticTokenType::METHOD, + CustomSemanticTokenType::FIELD, &modifiers, ); } } - LuaType::Union(union) if union.into_vec().iter().any(|typ| typ.is_function()) => { + LuaType::Union(union) if union.into_vec().iter().any(is_function_like_type) => { if let Some(field_name) = table_field.get_field_key()?.get_name() { + let modifiers = [ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::CALLABLE, + ]; builder.push_with_modifiers( field_name.syntax(), CustomSemanticTokenType::FIELD, - &[ - SemanticTokenModifier::DECLARATION, - CustomSemanticTokenModifier::CALLABLE, - ], + &modifiers, ); } } _ => { if let Some(field_name) = table_field.get_field_key()?.get_name() { - builder.push_with_modifier( + builder.push_with_modifiers( field_name.syntax(), CustomSemanticTokenType::FIELD, - SemanticTokenModifier::DECLARATION, + &[SemanticTokenModifier::DECLARATION], ); } } @@ -1034,7 +1202,6 @@ fn build_node_semantic_token( if let LuaLiteralToken::String(string_token) = literal_token && !builder.is_special_string_range(&string_token.get_range()) { - highlight_semantic_string_literal(builder, &call_expr, &string_token); fun_string_highlight(builder, semantic_model, call_expr, &string_token); } } @@ -1101,6 +1268,7 @@ fn handle_name_node( builder: &mut SemanticBuilder, node: &LuaSyntaxNode, name_token: &LuaNameToken, + allow_scoped_class_fallback: bool, ) -> Option<()> { let name_text = name_token.get_name_text(); @@ -1113,50 +1281,16 @@ fn handle_name_node( return Some(()); } - if is_scoped_scripted_class_name(semantic_model, name_text) { + let semantic_decl = semantic_model.find_decl(node.clone().into(), SemanticDeclLevel::NoTrace); + if allow_scoped_class_fallback + && is_scoped_scripted_class_name(semantic_model, name_text) + && !semantic_decl_is_local_decl(semantic_model, semantic_decl.as_ref()) + && !has_local_decl_in_scope(semantic_model, name_text, name_token) + { builder.push(name_token.syntax(), SemanticTokenType::CLASS); return Some(()); } - // 先查找声明,如果找不到声明再检查是否是 Lua 内置全局变量 - let semantic_decl = semantic_model.find_decl(node.clone().into(), SemanticDeclLevel::NoTrace); - if semantic_decl.is_none() { - if is_builtin_global_function(name_text) { - builder.push_with_modifiers( - name_token.syntax(), - SemanticTokenType::FUNCTION, - &[ - SemanticTokenModifier::DEFAULT_LIBRARY, - SemanticTokenModifier::READONLY, - ], - ); - return Some(()); - } - if is_builtin_global_constant(name_text) { - builder.push_with_modifiers( - name_token.syntax(), - SemanticTokenType::VARIABLE, - &[ - SemanticTokenModifier::DEFAULT_LIBRARY, - SemanticTokenModifier::READONLY, - CustomSemanticTokenModifier::GLOBAL, - ], - ); - return Some(()); - } - if is_builtin_global_namespace(name_text) { - builder.push_with_modifier( - name_token.syntax(), - SemanticTokenType::NAMESPACE, - SemanticTokenModifier::DEFAULT_LIBRARY, - ); - return Some(()); - } - if is_scoped_scripted_class_name(semantic_model, name_text) { - builder.push(name_token.syntax(), SemanticTokenType::CLASS); - return Some(()); - } - } let semantic_decl = semantic_decl?; match semantic_decl { LuaSemanticDeclId::Member(member_id) => { @@ -1186,7 +1320,7 @@ fn handle_name_node( let is_scoped_class_global = is_scoped_scripted_class_global(semantic_model, decl); if decl.is_global() && decl_is_default_library { - if should_treat_default_library_global_as_namespace(name_text, &decl_type) { + if matches!(decl_type, LuaType::Namespace(_)) { builder.push_with_modifier( name_token.syntax(), SemanticTokenType::NAMESPACE, @@ -1194,19 +1328,6 @@ fn handle_name_node( ); return Some(()); } - - if is_builtin_global_constant(name_text) { - builder.push_with_modifiers( - name_token.syntax(), - SemanticTokenType::VARIABLE, - &[ - SemanticTokenModifier::DEFAULT_LIBRARY, - SemanticTokenModifier::READONLY, - CustomSemanticTokenModifier::GLOBAL, - ], - ); - return Some(()); - } } let base_type = if is_scoped_class_global { @@ -1224,6 +1345,9 @@ fn handle_name_node( if is_declaration { modifiers.push(SemanticTokenModifier::DECLARATION); } + if decl.is_local() && !decl.is_param() { + modifiers.push(CustomSemanticTokenModifier::LOCAL); + } builder.push_with_modifiers( name_token.syntax(), SemanticTokenType::NAMESPACE, @@ -1232,76 +1356,58 @@ fn handle_name_node( return Some(()); } - // GMod namespace pattern: user-defined global tables acting as namespace - // containers (e.g. cityrp in cityrp.vehicle = cityrp.vehicle or {}). - // When a global variable with an unresolved/generic type is used as an index - // expression prefix, color it as NAMESPACE for better theme compatibility — - // NAMESPACE maps to entity.name.namespace which is styled in most VS Code themes. - if decl.is_global() - && !decl_is_callable - && !is_builtin_global_constant(name_text) - && !matches!( - decl_type, - glua_code_analysis::LuaType::Def(_) - | glua_code_analysis::LuaType::Ref(_) - | glua_code_analysis::LuaType::Namespace(_) - | glua_code_analysis::LuaType::ModuleRef(_) - ) - && is_index_expr_prefix(node) - { - builder.push_with_modifier( - name_token.syntax(), - SemanticTokenType::NAMESPACE, - CustomSemanticTokenModifier::GLOBAL, - ); - return Some(()); - } + let alias_token_type = local_alias_target_token_type(semantic_model, builder, decl); let mut modifiers = vec![]; enrich_modifiers_from_decl(semantic_model, &semantic_decl, &decl_type, &mut modifiers); - let (token_type, mut modifier) = match &decl_type { - LuaType::Def(type_id) => ( - semantic_token_type_for_type_decl(semantic_model, type_id) - .unwrap_or(SemanticTokenType::CLASS), - None, - ), - LuaType::Ref(ref_id) => { - if check_ref_is_require_def(semantic_model, decl, ref_id).unwrap_or(false) { - ( - SemanticTokenType::CLASS, - Some(SemanticTokenModifier::READONLY), - ) - } else { - let token_type = if decl.is_global() || is_scoped_class_global { - semantic_token_type_for_type_decl(semantic_model, ref_id) - .unwrap_or(base_type) + let (token_type, mut modifier) = if let Some(alias_token_type) = alias_token_type { + alias_token_type + } else { + match &decl_type { + LuaType::Def(type_id) => ( + semantic_token_type_for_type_decl(semantic_model, type_id) + .unwrap_or(SemanticTokenType::CLASS), + None, + ), + LuaType::Ref(ref_id) => { + if check_ref_is_require_def(semantic_model, decl, ref_id).unwrap_or(false) { + ( + SemanticTokenType::CLASS, + Some(SemanticTokenModifier::READONLY), + ) } else { + let token_type = if decl.is_global() || is_scoped_class_global { + semantic_token_type_for_type_decl(semantic_model, ref_id) + .unwrap_or(base_type) + } else { + base_type + }; + (token_type, None) + } + } + LuaType::Namespace(_) => ( + SemanticTokenType::NAMESPACE, + decl_is_default_library.then_some(SemanticTokenModifier::DEFAULT_LIBRARY), + ), + LuaType::Signature(signature) => { + let is_meta = semantic_model + .get_db() + .get_module_index() + .is_meta_file(&signature.get_file_id()); + let token_type = if decl.is_param() || decl.get_value_syntax_id().is_some() + { base_type + } else { + SemanticTokenType::FUNCTION }; - (token_type, None) + ( + token_type, + is_meta.then_some(SemanticTokenModifier::DEFAULT_LIBRARY), + ) } + LuaType::DocFunction(_) | LuaType::Union(_) => (base_type, None), + _ => (base_type, None), } - LuaType::Namespace(_) => ( - SemanticTokenType::NAMESPACE, - decl_is_default_library.then_some(SemanticTokenModifier::DEFAULT_LIBRARY), - ), - LuaType::Signature(signature) => { - let is_meta = semantic_model - .get_db() - .get_module_index() - .is_meta_file(&signature.get_file_id()); - let token_type = if decl.is_param() || decl.get_value_syntax_id().is_some() { - base_type - } else { - SemanticTokenType::FUNCTION - }; - ( - token_type, - is_meta.then_some(SemanticTokenModifier::DEFAULT_LIBRARY), - ) - } - LuaType::DocFunction(_) | LuaType::Union(_) => (base_type, None), - _ => (base_type, None), }; if modifier.is_none() && is_decl_readonly(decl) { @@ -1320,6 +1426,18 @@ fn handle_name_node( if token_type == SemanticTokenType::VARIABLE || token_type == SemanticTokenType::PARAMETER { + if decl.is_global() + && is_index_expr_prefix(node) + && global_path_has_class_like_children( + semantic_model, + builder, + &GlobalId::new(decl.get_name()), + ) + { + builder.push(name_token.syntax(), SemanticTokenType::NAMESPACE); + return Some(()); + } + if decl.is_global() { modifiers.push(CustomSemanticTokenModifier::GLOBAL); } else if !decl.is_param() && !is_scoped_class_global { @@ -1330,6 +1448,14 @@ fn handle_name_node( modifiers.push(CustomSemanticTokenModifier::OBJECT); } } + if token_type != SemanticTokenType::VARIABLE + && token_type != SemanticTokenType::PARAMETER + && decl.is_local() + && !decl.is_param() + && decl.get_value_syntax_id().is_some() + { + modifiers.push(CustomSemanticTokenModifier::LOCAL); + } if let Some(modifier) = modifier { modifiers.push(modifier); } @@ -1365,17 +1491,7 @@ fn render_callable_name_token( name_text: &str, value_type: Option, ) -> Option<()> { - if is_builtin_global_function(name_text) { - return builder.push_with_modifiers( - token, - SemanticTokenType::FUNCTION, - &[ - SemanticTokenModifier::DEFAULT_LIBRARY, - SemanticTokenModifier::READONLY, - ], - ); - } - + let _ = name_text; match value_type { Some(LuaType::Signature(signature)) => { let is_meta = semantic_model @@ -1438,28 +1554,6 @@ fn callable_parameter_type(semantic_model: &SemanticModel, decl: &LuaDecl) -> bo } } -fn is_builtin_global_constant(name_text: &str) -> bool { - matches!(name_text, "CLIENT" | "SERVER" | "MENU_DLL" | "_VERSION") -} - -fn is_builtin_global_namespace(name_text: &str) -> bool { - matches!( - name_text, - "_G" | "_ENV" - | "arg" - | "package" - | "coroutine" - | "string" - | "utf8" - | "table" - | "math" - | "io" - | "os" - | "debug" - | "bit32" - ) -} - fn is_default_library_decl(semantic_model: &SemanticModel, decl: &LuaDecl) -> bool { let module_index = semantic_model.get_db().get_module_index(); let file_id = decl.get_file_id(); @@ -1468,18 +1562,6 @@ fn is_default_library_decl(semantic_model: &SemanticModel, decl: &LuaDecl) -> bo || module_index.is_meta_file(&file_id) } -fn should_treat_default_library_global_as_namespace(name_text: &str, decl_type: &LuaType) -> bool { - if is_builtin_global_constant(name_text) || is_builtin_global_function(name_text) { - return false; - } - - if is_builtin_global_namespace(name_text) { - return true; - } - - !matches!(decl_type, LuaType::Def(_)) && !is_function_like_type(decl_type) -} - fn semantic_token_type_for_type_decl( semantic_model: &SemanticModel, type_id: &LuaTypeDeclId, @@ -1509,19 +1591,60 @@ fn is_object_like_decl_type( return false; } + is_object_like_value_type(semantic_model, decl_type) +} + +fn is_object_like_value_type(semantic_model: &SemanticModel, decl_type: &LuaType) -> bool { match decl_type { LuaType::Ref(type_id) => semantic_model .get_db() .get_type_index() .get_type_decl(type_id) .is_some_and(|type_decl| type_decl.is_class()), - LuaType::Instance(_) => true, + LuaType::Instance(_) + | LuaType::Object(_) + | LuaType::Table + | LuaType::TableConst(_) + | LuaType::TableGeneric(_) + | LuaType::TableOf(_) => true, + LuaType::Union(union) => union + .into_vec() + .iter() + .any(|typ| is_object_like_value_type(semantic_model, typ)), _ => false, } } fn is_scoped_scripted_class_global(semantic_model: &SemanticModel, decl: &LuaDecl) -> bool { - is_scoped_scripted_class_name(semantic_model, decl.get_name()) + decl.is_global() && is_scoped_scripted_class_name(semantic_model, decl.get_name()) +} + +fn semantic_decl_is_local_decl( + semantic_model: &SemanticModel, + semantic_decl: Option<&LuaSemanticDeclId>, +) -> bool { + let Some(LuaSemanticDeclId::LuaDecl(decl_id)) = semantic_decl else { + return false; + }; + + semantic_model + .get_db() + .get_decl_index() + .get_decl(decl_id) + .is_some_and(|decl| decl.is_local()) +} + +fn has_local_decl_in_scope( + semantic_model: &SemanticModel, + name: &str, + name_token: &LuaNameToken, +) -> bool { + semantic_model + .get_db() + .get_decl_index() + .get_decl_tree(&semantic_model.get_file_id()) + .and_then(|tree| tree.find_local_decl(name, name_token.syntax().text_range().start())) + .is_some() } fn is_scoped_scripted_class_name(semantic_model: &SemanticModel, name: &str) -> bool { @@ -1545,37 +1668,6 @@ fn is_scoped_scripted_class_name(semantic_model: &SemanticModel, name: &str) -> .is_some_and(|scope_match| scope_match.definition.class_global == name) } -fn is_builtin_global_function(name_text: &str) -> bool { - matches!( - name_text, - "require" - | "load" - | "loadfile" - | "dofile" - | "print" - | "assert" - | "error" - | "warn" - | "type" - | "getmetatable" - | "setmetatable" - | "rawget" - | "rawset" - | "rawequal" - | "rawlen" - | "next" - | "pairs" - | "ipairs" - | "tostring" - | "tonumber" - | "select" - | "unpack" - | "pcall" - | "xpcall" - | "collectgarbage" - ) -} - fn is_function_like_type(decl_type: &LuaType) -> bool { match decl_type { LuaType::DocFunction(_) => true, @@ -1584,6 +1676,78 @@ fn is_function_like_type(decl_type: &LuaType) -> bool { } } +fn is_method_like_member( + semantic_model: &SemanticModel, + member: &glua_code_analysis::LuaMember, +) -> bool { + let module_index = semantic_model.get_db().get_module_index(); + let file_id = member.get_file_id(); + if module_index.is_std(&file_id) + || module_index.is_library(&file_id) + || module_index.is_meta_file(&file_id) + { + return true; + } + + matches!( + member.get_feature(), + LuaMemberFeature::FileMethodDecl + | LuaMemberFeature::MetaMethodDecl + | LuaMemberFeature::MetaDefine + ) +} + +fn is_default_library_type(semantic_model: &SemanticModel, decl_type: &LuaType) -> bool { + let module_index = semantic_model.get_db().get_module_index(); + match decl_type { + LuaType::Signature(signature_id) => { + let file_id = signature_id.get_file_id(); + module_index.is_std(&file_id) + || module_index.is_library(&file_id) + || module_index.is_meta_file(&file_id) + } + LuaType::Union(union) => union + .into_vec() + .iter() + .any(|typ| is_default_library_type(semantic_model, typ)), + _ => false, + } +} + +fn index_prefix_is_namespace_like( + semantic_model: &SemanticModel, + index_expr: &glua_parser::LuaIndexExpr, +) -> bool { + let Some(prefix_expr) = index_expr.get_prefix_expr() else { + return false; + }; + + if semantic_model + .infer_expr(prefix_expr.clone()) + .ok() + .is_some_and(|typ| matches!(typ, LuaType::Namespace(_))) + { + return true; + } + + let LuaExpr::NameExpr(prefix_name_expr) = prefix_expr else { + return false; + }; + + let Some(LuaSemanticDeclId::LuaDecl(decl_id)) = semantic_model.find_decl( + prefix_name_expr.syntax().clone().into(), + SemanticDeclLevel::NoTrace, + ) else { + return false; + }; + let Some(decl) = semantic_model.get_db().get_decl_index().get_decl(&decl_id) else { + return false; + }; + let decl_type = semantic_model.get_type(decl_id.into()); + + is_default_library_decl(semantic_model, decl) && matches!(decl_type, LuaType::Namespace(_)) +} + fn push_name_or_syntax_with_context_modifiers( builder: &mut SemanticBuilder, token: &LuaSyntaxToken, @@ -1615,27 +1779,6 @@ fn is_modification_target(node: &LuaSyntaxNode) -> bool { .any(|var| var.syntax() == name_expr.syntax()) } -fn highlight_semantic_string_literal( - builder: &mut SemanticBuilder, - call_expr: &LuaCallExpr, - string_token: &glua_parser::LuaStringToken, -) { - let Some(call_path) = call_expr.get_access_path() else { - return; - }; - - let token = string_token.syntax(); - match call_path.as_str() { - "hook.Add" | "hook.Run" | "hook.Call" => { - let _ = builder.push(token, SemanticTokenType::EVENT); - } - "vgui.Register" | "derma.DefineControl" => { - let _ = builder.push(token, SemanticTokenType::CLASS); - } - _ => {} - } -} - fn is_decl_readonly(decl: &LuaDecl) -> bool { matches!( &decl.extra, @@ -1690,12 +1833,7 @@ fn owner_has_multiple_callable_children(db: &DbIndex, owner: &LuaMemberOwner) -> return false; }; - members - .iter() - .filter(|child| member_is_callable(db, child)) - .take(2) - .count() - >= 2 + members.iter().any(|child| member_is_callable(db, child)) } fn member_is_callable(db: &DbIndex, child: &glua_code_analysis::LuaMember) -> bool { @@ -1842,6 +1980,170 @@ fn check_ref_is_require_def( } } +fn local_alias_target_token_type( + semantic_model: &SemanticModel, + builder: &mut SemanticBuilder, + decl: &LuaDecl, +) -> Option<(SemanticTokenType, Option)> { + if !decl.is_local() || decl.is_param() { + return None; + } + + let decl_id = decl.get_id(); + if let Some(cached) = builder.cached_alias_target(&decl_id) { + return cached; + } + + let result = compute_local_alias_target_token_type(semantic_model, decl); + builder.cache_alias_target(decl_id, result.clone()); + result +} + +fn compute_local_alias_target_token_type( + semantic_model: &SemanticModel, + decl: &LuaDecl, +) -> Option<(SemanticTokenType, Option)> { + let value_syntax_id = decl.get_value_syntax_id()?; + let root = semantic_model + .get_db() + .get_vfs() + .get_syntax_tree(&decl.get_file_id())? + .get_red_root(); + let value_node = value_syntax_id.to_node_from_root(&root)?; + let value_expr = LuaExpr::cast(value_node)?; + + if let Some(alias_token_type) = + inferred_alias_target_token_type(semantic_model, value_expr.clone()) + { + return Some(alias_token_type); + } + + if let Some(alias_token_type) = resolved_alias_target_token_type(semantic_model, &value_expr) { + return Some(alias_token_type); + } + + lexical_global_alias_target_token_type(semantic_model, &value_expr) +} + +fn resolved_alias_target_token_type( + semantic_model: &SemanticModel, + value_expr: &LuaExpr, +) -> Option<(SemanticTokenType, Option)> { + let semantic_decl = semantic_model.find_decl( + value_expr.syntax().clone().into(), + SemanticDeclLevel::NoTrace, + )?; + + match semantic_decl { + LuaSemanticDeclId::LuaDecl(target_decl_id) => { + let target_decl = semantic_model + .get_db() + .get_decl_index() + .get_decl(&target_decl_id)?; + let target_type = semantic_model.get_type(target_decl_id.into()); + if target_decl.is_global() + && is_default_library_decl(semantic_model, target_decl) + && matches!(target_type, LuaType::Namespace(_)) + { + return Some(( + SemanticTokenType::NAMESPACE, + Some(SemanticTokenModifier::DEFAULT_LIBRARY), + )); + } + + match target_type { + LuaType::Def(type_id) => { + semantic_token_type_for_type_decl(semantic_model, &type_id) + .map(|token_type| (token_type, None)) + } + LuaType::Namespace(_) => Some((SemanticTokenType::NAMESPACE, None)), + _ => None, + } + } + LuaSemanticDeclId::Member(member_id) => { + let target_type = semantic_model.get_type(member_id.into()); + match target_type { + LuaType::Def(type_id) => { + semantic_token_type_for_type_decl(semantic_model, &type_id) + .map(|token_type| (token_type, None)) + } + LuaType::Namespace(_) => Some((SemanticTokenType::NAMESPACE, None)), + _ => None, + } + } + _ => None, + } +} + +fn lexical_global_alias_target_token_type( + semantic_model: &SemanticModel, + value_expr: &LuaExpr, +) -> Option<(SemanticTokenType, Option)> { + if expr_access_path_root_is_local(semantic_model, value_expr) { + return None; + } + + let path = expr_access_path(value_expr)?; + let type_id = LuaTypeDeclId::global(&path); + semantic_token_type_for_type_decl(semantic_model, &type_id).map(|token_type| (token_type, None)) +} + +fn inferred_alias_target_token_type( + semantic_model: &SemanticModel, + value_expr: LuaExpr, +) -> Option<(SemanticTokenType, Option)> { + let value_type = semantic_model.infer_expr(value_expr.clone()).ok()?; + match value_type { + LuaType::Def(type_id) => semantic_token_type_for_type_decl(semantic_model, &type_id) + .map(|token_type| (token_type, None)), + LuaType::Namespace(_) => Some((SemanticTokenType::NAMESPACE, None)), + _ => None, + } +} + +fn expr_access_path(value_expr: &LuaExpr) -> Option { + match value_expr { + LuaExpr::NameExpr(name_expr) => name_expr.get_access_path(), + LuaExpr::IndexExpr(index_expr) => index_expr.get_access_path(), + _ => None, + } +} + +fn expr_access_path_root_is_local(semantic_model: &SemanticModel, value_expr: &LuaExpr) -> bool { + let root_name_expr = match value_expr { + LuaExpr::NameExpr(name_expr) => Some(name_expr.clone()), + LuaExpr::IndexExpr(index_expr) => { + let mut prefix = index_expr.get_prefix_expr(); + while let Some(LuaExpr::IndexExpr(next_index)) = prefix { + prefix = next_index.get_prefix_expr(); + } + + match prefix { + Some(LuaExpr::NameExpr(name_expr)) => Some(name_expr), + _ => None, + } + } + _ => None, + }; + + root_name_expr + .and_then(|name_expr| { + semantic_model.find_decl( + name_expr.syntax().clone().into(), + SemanticDeclLevel::NoTrace, + ) + }) + .and_then(|semantic_decl| match semantic_decl { + LuaSemanticDeclId::LuaDecl(decl_id) => semantic_model + .get_db() + .get_decl_index() + .get_decl(&decl_id) + .map(|decl| decl.is_local()), + _ => Some(false), + }) + .unwrap_or(false) +} + /// 是否为 `local x = require(...)` 的导入别名 fn is_require_decl(semantic_model: &SemanticModel, decl: &LuaDecl) -> bool { parse_require_module_info(semantic_model, decl).is_some() diff --git a/crates/glua_ls/src/handlers/semantic_token/escape_sequence_highlight.rs b/crates/glua_ls/src/handlers/semantic_token/escape_sequence_highlight.rs new file mode 100644 index 00000000..b4bfdd7c --- /dev/null +++ b/crates/glua_ls/src/handlers/semantic_token/escape_sequence_highlight.rs @@ -0,0 +1,369 @@ +use glua_parser::LuaSyntaxToken; +use lsp_types::{SemanticTokenModifier, SemanticTokenType}; +use rowan::{TextRange, TextSize}; + +use crate::handlers::semantic_token::semantic_token_builder::SemanticBuilder; + +/// Highlight a regular (non-long) Lua string token, emitting the escape sequences as +/// separate semantic tokens so editors can color them distinctly. +/// +/// The string is split into non-overlapping segments: literal runs (including the +/// surrounding quotes) are emitted as `STRING`, valid escape sequences as +/// `STRING + MODIFICATION`, and invalid escape sequences as `STRING + DEPRECATED`. +/// +/// Only call this for `TkString` tokens. Long strings (`[[ ... ]]`) do not process +/// escape sequences and must keep their single `STRING` token. +pub fn highlight_string_escapes(builder: &mut SemanticBuilder, token: &LuaSyntaxToken) { + let text = token.text(); + let base = token.text_range().start(); + + // Byte offset (within `text`) where the current literal run started. + let mut run_start: usize = 0; + let mut chars = text.char_indices().peekable(); + + while let Some((idx, c)) = chars.next() { + if c != '\\' { + continue; + } + + // Flush the literal run before this backslash. + push_segment(builder, text, base, run_start, idx, SegmentKind::Literal); + + // Consume the escape sequence and determine whether it is valid. + let valid = consume_escape(&mut chars); + // Where the escape ends: the next char_indices position, or end of text. + let escape_end = chars.peek().map(|(i, _)| *i).unwrap_or(text.len()); + let kind = if valid { + SegmentKind::ValidEscape + } else { + SegmentKind::InvalidEscape + }; + push_segment(builder, text, base, idx, escape_end, kind); + + run_start = escape_end; + } + + // Flush the trailing literal run (includes the closing quote, if any). + push_segment( + builder, + text, + base, + run_start, + text.len(), + SegmentKind::Literal, + ); +} + +enum SegmentKind { + Literal, + ValidEscape, + InvalidEscape, +} + +fn push_segment( + builder: &mut SemanticBuilder, + text: &str, + base: TextSize, + start: usize, + end: usize, + kind: SegmentKind, +) { + if start >= end { + return; + } + let slice = &text[start..end]; + let range = TextRange::new( + base + TextSize::from(start as u32), + base + TextSize::from(end as u32), + ); + let modifiers: &[SemanticTokenModifier] = match kind { + SegmentKind::Literal => &[], + SegmentKind::ValidEscape => &[SemanticTokenModifier::MODIFICATION], + SegmentKind::InvalidEscape => &[SemanticTokenModifier::DEPRECATED], + }; + builder.push_at_range(slice, range, SemanticTokenType::STRING, modifiers); +} + +/// Consume the characters of an escape sequence after the leading backslash has already +/// been read. The iterator is positioned just past the backslash. Returns whether the +/// escape sequence is valid. +/// +/// Mirrors `normal_string_value` in +/// `crates/glua_parser/src/syntax/node/token/string_analyzer.rs`. +fn consume_escape(chars: &mut std::iter::Peekable) -> bool +where + I: Iterator, +{ + let Some((_, next)) = chars.next() else { + // Trailing backslash at end of token: invalid. + return false; + }; + + match next { + 'a' | 'b' | 'f' | 'n' | 'r' | 't' | 'v' | '\\' | '\'' | '"' | '\r' | '\n' => true, + 'z' => { + while let Some((_, c)) = chars.peek() { + if !c.is_ascii_whitespace() { + break; + } + chars.next(); + } + true + } + 'x' => { + // Exactly two hex digits. + let mut count = 0; + while count < 2 { + match chars.peek() { + Some((_, d)) if d.is_ascii_hexdigit() => { + chars.next(); + count += 1; + } + _ => break, + } + } + count == 2 + } + 'u' => { + // \u{ hex+ } + if !matches!(chars.peek(), Some((_, '{'))) { + return false; + } + chars.next(); // consume '{' + let mut hex = String::new(); + let mut closed = false; + while let Some((_, d)) = chars.peek().copied() { + if d == '}' { + chars.next(); + closed = true; + break; + } + if !d.is_ascii_hexdigit() { + break; + } + hex.push(d); + chars.next(); + } + if !closed || hex.is_empty() { + return false; + } + // Must be a valid Unicode scalar value. + u32::from_str_radix(&hex, 16) + .ok() + .and_then(char::from_u32) + .is_some() + } + '0'..='9' => { + // Up to three decimal digits total, value must fit in a byte (0-255). + let mut dec = String::new(); + dec.push(next); + while dec.len() < 3 { + match chars.peek() { + Some((_, d)) if d.is_ascii_digit() => { + dec.push(*d); + chars.next(); + } + _ => break, + } + } + dec.parse::().is_ok() + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Walk a raw string token's text the same way `highlight_string_escapes` does and + /// return the segments as (relative_start, len, kind) tuples. This exercises the + /// segmentation logic without constructing a real `SemanticBuilder`. + fn segments(text: &str) -> Vec<(usize, usize, &'static str)> { + let mut out = Vec::new(); + let mut run_start = 0usize; + let mut chars = text.char_indices().peekable(); + + while let Some((idx, c)) = chars.next() { + if c != '\\' { + continue; + } + if idx > run_start { + out.push((run_start, idx - run_start, "string")); + } + let valid = consume_escape(&mut chars); + let escape_end = chars.peek().map(|(i, _)| *i).unwrap_or(text.len()); + out.push(( + idx, + escape_end - idx, + if valid { "escape" } else { "invalid" }, + )); + run_start = escape_end; + } + if text.len() > run_start { + out.push((run_start, text.len() - run_start, "string")); + } + out + } + + #[test] + fn plain_string_is_single_run() { + assert_eq!(segments("\"abc\""), vec![(0, 5, "string")]); + } + + #[test] + fn empty_and_unterminated() { + assert_eq!(segments("\"\""), vec![(0, 2, "string")]); + assert_eq!(segments("\""), vec![(0, 1, "string")]); + assert_eq!(segments("\"abc"), vec![(0, 4, "string")]); + } + + #[test] + fn simple_escapes() { + // "a\n" -> `"a`, `\n`, `"` + assert_eq!( + segments("\"a\\n\""), + vec![(0, 2, "string"), (2, 2, "escape"), (4, 1, "string")] + ); + // backslash, quote, tab, z, bell + for esc in ["\\\\", "\\\"", "\\t", "\\z", "\\a"] { + let s = format!("\"{esc}\""); + assert_eq!( + segments(&s), + vec![(0, 1, "string"), (1, 2, "escape"), (3, 1, "string")], + "escape {esc}" + ); + } + } + + #[test] + fn line_continuation_escape() { + // \ + assert_eq!( + segments("\"\\\n\""), + vec![(0, 1, "string"), (1, 2, "escape"), (3, 1, "string")] + ); + } + + #[test] + fn z_escape_consumes_following_whitespace() { + assert_eq!( + segments("\"a\\z\n b\""), + vec![(0, 2, "string"), (2, 5, "escape"), (7, 2, "string")] + ); + } + + #[test] + fn z_escape_does_not_consume_unicode_whitespace() { + assert_eq!( + segments("\"\\z\u{00a0}x\""), + vec![(0, 1, "string"), (1, 2, "escape"), (3, 4, "string")] + ); + } + + #[test] + fn decimal_escapes() { + // \65 (three given but only valid up to 255) + assert_eq!( + segments("\"\\65\""), + vec![(0, 1, "string"), (1, 3, "escape"), (4, 1, "string")] + ); + // \9 single digit + assert_eq!( + segments("\"\\9\""), + vec![(0, 1, "string"), (1, 2, "escape"), (3, 1, "string")] + ); + // \255 ok, \256 overflows a byte -> invalid + assert_eq!( + segments("\"\\255\""), + vec![(0, 1, "string"), (1, 4, "escape"), (5, 1, "string")] + ); + assert_eq!( + segments("\"\\256\""), + vec![(0, 1, "string"), (1, 4, "invalid"), (5, 1, "string")] + ); + } + + #[test] + fn hex_escapes() { + // \x41 -> `\x41` + assert_eq!( + segments("\"\\x41\""), + vec![(0, 1, "string"), (1, 4, "escape"), (5, 1, "string")] + ); + // \x4 (only one hex digit) -> invalid, length covers `\x4` + assert_eq!( + segments("\"\\x4\""), + vec![(0, 1, "string"), (1, 3, "invalid"), (4, 1, "string")] + ); + // \xZZ (no hex digits) -> invalid, length covers `\x` + assert_eq!( + segments("\"\\xZZ\""), + vec![ + (0, 1, "string"), + (1, 2, "invalid"), + (3, 3, "string") // ZZ" + ] + ); + } + + #[test] + fn unicode_escapes() { + // \u{48} valid + assert_eq!( + segments("\"\\u{48}\""), + vec![(0, 1, "string"), (1, 6, "escape"), (7, 1, "string")] + ); + // \u{} empty -> invalid + assert_eq!( + segments("\"\\u{}\""), + vec![(0, 1, "string"), (1, 4, "invalid"), (5, 1, "string")] + ); + // \u48 missing brace -> invalid (covers just `\u`) + assert_eq!( + segments("\"\\u48\""), + vec![(0, 1, "string"), (1, 2, "invalid"), (3, 3, "string")] + ); + // \u{110000} out of range -> invalid + assert_eq!( + segments("\"\\u{110000}\""), + vec![(0, 1, "string"), (1, 10, "invalid"), (11, 1, "string")] + ); + } + + #[test] + fn invalid_escape() { + // \q is not a valid escape + assert_eq!( + segments("\"\\q\""), + vec![(0, 1, "string"), (1, 2, "invalid"), (3, 1, "string")] + ); + // trailing backslash + assert_eq!(segments("\"\\"), vec![(0, 1, "string"), (1, 1, "invalid")]); + } + + #[test] + fn multibyte_literal_between_escapes() { + // "é\n" — é is 2 bytes (U+00E9). Offsets must be byte-based. + let s = "\"\u{e9}\\n\""; + // bytes: 0:" 1-2:é 3:\ 4:n 5:" + assert_eq!( + segments(s), + vec![(0, 3, "string"), (3, 2, "escape"), (5, 1, "string")] + ); + } + + #[test] + fn consecutive_escapes() { + // "\n\t" -> `"`, `\n`, `\t`, `"` + assert_eq!( + segments("\"\\n\\t\""), + vec![ + (0, 1, "string"), + (1, 2, "escape"), + (3, 2, "escape"), + (5, 1, "string") + ] + ); + } +} diff --git a/crates/glua_ls/src/handlers/semantic_token/mod.rs b/crates/glua_ls/src/handlers/semantic_token/mod.rs index 816e58ff..f7209e79 100644 --- a/crates/glua_ls/src/handlers/semantic_token/mod.rs +++ b/crates/glua_ls/src/handlers/semantic_token/mod.rs @@ -1,4 +1,5 @@ mod build_semantic_tokens; +mod escape_sequence_highlight; mod function_string_highlight; mod language_injector; mod semantic_token_builder; diff --git a/crates/glua_ls/src/handlers/semantic_token/semantic_token_builder.rs b/crates/glua_ls/src/handlers/semantic_token/semantic_token_builder.rs index cd0c0570..3136741d 100644 --- a/crates/glua_ls/src/handlers/semantic_token/semantic_token_builder.rs +++ b/crates/glua_ls/src/handlers/semantic_token/semantic_token_builder.rs @@ -1,4 +1,4 @@ -use glua_code_analysis::{GlobalId, LuaDocument}; +use glua_code_analysis::{GlobalId, LuaDeclId, LuaDocument}; use glua_parser::LuaSyntaxToken; use lsp_types::{SemanticToken, SemanticTokenModifier, SemanticTokenType}; use rowan::{TextRange, TextSize}; @@ -12,6 +12,7 @@ impl CustomSemanticTokenType { // neovim supports custom semantic token types, we add a custom type for delimiter pub const DELIMITER: SemanticTokenType = SemanticTokenType::new("delimiter"); pub const FIELD: SemanticTokenType = SemanticTokenType::new("field"); + pub const LABEL: SemanticTokenType = SemanticTokenType::new("label"); } pub struct CustomSemanticTokenModifier; @@ -49,6 +50,7 @@ pub const SEMANTIC_TOKEN_TYPES: &[SemanticTokenType] = &[ // Custom types CustomSemanticTokenType::DELIMITER, CustomSemanticTokenType::FIELD, + CustomSemanticTokenType::LABEL, ]; pub const SEMANTIC_TOKEN_MODIFIERS: &[SemanticTokenModifier] = &[ @@ -92,6 +94,8 @@ pub struct SemanticBuilder<'a> { data: HashMap, string_special_range: HashSet, class_like_global_cache: HashMap, + alias_target_cache: + HashMap)>>, } impl<'a> SemanticBuilder<'a> { @@ -118,6 +122,7 @@ impl<'a> SemanticBuilder<'a> { data: HashMap::new(), string_special_range: HashSet::new(), class_like_global_cache: HashMap::new(), + alias_target_cache: HashMap::new(), } } @@ -163,7 +168,7 @@ impl<'a> SemanticBuilder<'a> { self.data .insert(position, SemanticTokenData::MultiLine(multi_line_data)); } else { - let length = text.chars().count() as u32; + let length = text.encode_utf16().count() as u32; self.data.insert( position, SemanticTokenData::Basic(BasicSemanticTokenData { @@ -201,6 +206,21 @@ impl<'a> SemanticBuilder<'a> { Some(()) } + pub fn push_with_modifier_force( + &mut self, + token: &LuaSyntaxToken, + ty: SemanticTokenType, + modifier: SemanticTokenModifier, + ) -> Option<()> { + self.data.remove(&token.text_range().start()); + self.push_with_modifier(token, ty, modifier) + } + + pub fn push_force(&mut self, token: &LuaSyntaxToken, ty: SemanticTokenType) -> Option<()> { + self.data.remove(&token.text_range().start()); + self.push(token, ty) + } + pub fn push_at_position( &mut self, position: TextSize, @@ -234,6 +254,21 @@ impl<'a> SemanticBuilder<'a> { .insert(global_id, is_class_like); } + pub fn cached_alias_target( + &self, + decl_id: &LuaDeclId, + ) -> Option)>> { + self.alias_target_cache.get(decl_id).cloned() + } + + pub fn cache_alias_target( + &mut self, + decl_id: LuaDeclId, + target: Option<(SemanticTokenType, Option)>, + ) { + self.alias_target_cache.insert(decl_id, target); + } + pub fn push_at_range( &mut self, token_text: &str, diff --git a/crates/glua_ls/src/handlers/test/hover_function_test.rs b/crates/glua_ls/src/handlers/test/hover_function_test.rs index 89830b05..f6a9c956 100644 --- a/crates/glua_ls/src/handlers/test/hover_function_test.rs +++ b/crates/glua_ls/src/handlers/test/hover_function_test.rs @@ -805,7 +805,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -903,7 +903,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/shared.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -976,7 +976,7 @@ mod tests { )?; let server_file_id = ws.def_file("gamemode/init.lua", &server_content); let server_hover = - crate::handlers::hover::hover(&ws.analysis, server_file_id, server_position) + crate::handlers::hover::hover(&ws.analysis, server_file_id, server_position, None) .ok_or("expected server hover") .or_fail()?; @@ -1001,7 +1001,7 @@ mod tests { )?; let client_file_id = ws.def_file("gamemode/cl_init.lua", &client_content); let client_hover = - crate::handlers::hover::hover(&ws.analysis, client_file_id, client_position) + crate::handlers::hover::hover(&ws.analysis, client_file_id, client_position, None) .ok_or("expected client hover") .or_fail()?; @@ -1107,7 +1107,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1154,7 +1154,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1204,7 +1204,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1256,7 +1256,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1316,7 +1316,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1367,7 +1367,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; diff --git a/crates/glua_ls/src/handlers/test/hover_test.rs b/crates/glua_ls/src/handlers/test/hover_test.rs index 9a2628b8..22cd221f 100644 --- a/crates/glua_ls/src/handlers/test/hover_test.rs +++ b/crates/glua_ls/src/handlers/test/hover_test.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::handlers::test_lib::{ProviderVirtualWorkspace, VirtualHoverResult, check}; - use glua_code_analysis::EmmyrcGmodScriptedClassScopeEntry; + use glua_code_analysis::{EmmyrcGmodScriptedClassScopeEntry, RenderLevel}; use googletest::prelude::*; use lsp_types::HoverContents; @@ -297,6 +297,31 @@ mod tests { Ok(()) } + #[gtest] + fn test_hover_unannotated_param_with_gmod_name_hint() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + + check!(ws.check_hover( + r#" + ---@class Entity + + local function foo(ent) + local value = ent + end + "#, + VirtualHoverResult { + value: dedent( + r#" + ```lua + local ent: Entity + ``` + "# + ) + }, + )); + Ok(()) + } + #[gtest] fn test_hover_param_func() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -374,7 +399,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -410,7 +435,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -447,7 +472,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -484,7 +509,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -501,6 +526,85 @@ mod tests { Ok(()) } + #[gtest] + fn test_hover_dynamic_key_read_from_known_table_fields_stays_table() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = ws.get_emmyrc(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.infer_dynamic_fields = true; + ws.update_emmyrc(emmyrc); + + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + ---@class CSEnt + local CSEnt = {} + function CSEnt:Remove() + end + + ---@param ent any + ---@return boolean + local function IsValid(ent) + end + + ---@param modelPath string + ---@param renderGroup any + ---@return CSEnt + local function ClientsideModel(modelPath, renderGroup) + end + + local PREVIEW_RENDER_GROUP = 0 + Glide = {} + local Editor = Glide.VehicleLayoutEditor or {} + Glide.VehicleLayoutEditor = Editor + + Editor.previewModels = Editor.previewModels or { + seats = {}, + wheels = {} + } + + function Editor:GetPreviewEntity(kind, itemId, modelPath) + if not modelPath or modelPath == "" then return end + self.previewModels = self.previewModels or { seats = {}, wheels = {} } + local pool = self.previewModels[kind] + if not pool then return end + + local entry = pool[itemId] + if not entry or not IsValid(entry.ent) or entry.model ~= modelPath then + if entry and IsValid(entry.ent) then + entry.ent:Remove() + end + + local ent = ClientsideModel(modelPath, PREVIEW_RENDER_GROUP) + if not IsValid(ent) then + pool[itemId] = nil + return + end + + entry = { ent = ent, model = modelPath } + pool[itemId] = entry + end + + for _, value in pairs(pool) do + end + + return entry.ent + end + "#, + )?; + let file_id = ws.def_file("lua/glide/client/vehicle_layout_editor.lua", &content); + let value = extract_hover_markdown(&ws, file_id, position); + + assert!( + value.contains("local pool: table"), + "dynamic read of seats/wheels should hover as a table, got: {value}" + ); + assert!( + !value.contains("[unknown]"), + "unknown dynamic write key must not become the displayed table shape, got: {value}" + ); + Ok(()) + } + #[gtest] fn test_decl_desc() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -632,6 +736,48 @@ mod tests { Ok(()) } + #[gtest] + fn test_table_escape_string_keys_hover_cleanly() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + check!( + ws.check_hover_with_level( + r#" + local EscapeStringMap = { + ["\a"] = "\\a", + ["\b"] = "\\b", + ["\f"] = "\\f", + ["\n"] = "\\n", + ["\r"] = "\\r", + ["\t"] = "\\t", + ["\v"] = "\\v", + ["\\"] = "\\\\", + ["\""] = "\\\"", + ["\'"] = "\\\'" + } + "#, + VirtualHoverResult { + value: r##"```lua +local EscapeStringMap: { + ["\a"]: string = "\\a", + ["\b"]: string = "\\b", + ["\f"]: string = "\\f", + ["\n"]: string = "\\n", + ["\r"]: string = "\\r", + ["\t"]: string = "\\t", + ["\v"]: string = "\\v", + ["\\"]: string = "\\\\", + ["\""]: string = "\\\"", + ["'"]: string = "\\'", +} +```"## + .to_string(), + }, + Some(RenderLevel::DetailedCount(12)), + ) + ); + Ok(()) + } + #[gtest] fn test_field_key() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -854,7 +1000,7 @@ mod tests { "#, )?; let file_id = ws.def_file("cityrp/plugins/vehicles/sh_plugin.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -882,7 +1028,7 @@ mod tests { "#, )?; let file_id = ws.def_file("cityrp/plugins/vehicles/sh_plugin.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -922,7 +1068,7 @@ mod tests { "cityrp/entities/entities/cityrp_money/sh_init.lua", &content, ); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -962,7 +1108,7 @@ mod tests { "cityrp/entities/entities/cityrp_inventory/init.lua", &content, ); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1019,7 +1165,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1096,7 +1242,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1155,7 +1301,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1198,7 +1344,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1250,7 +1396,7 @@ mod tests { "#, )?; let file_id = ws.def_file("sv_badge_priority.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1306,7 +1452,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/shared.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1358,7 +1504,7 @@ mod tests { "#, )?; let file_id = ws.def_file("sh_test_tbl.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1408,7 +1554,7 @@ mod tests { "#, )?; let file_id = ws.def_file("sh_global_function.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1455,7 +1601,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/shared.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -1582,7 +1728,7 @@ mod tests { "#, )?; let file_id = ws.def_file("use.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; let HoverContents::Markup(markup) = hover.contents else { @@ -1621,7 +1767,7 @@ mod tests { "#, )?; let file_id = ws.def(&content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; let HoverContents::Markup(markup) = hover.contents else { @@ -2292,7 +2438,7 @@ mod tests { "#, ))?; let file_id = ws.def_file("cityrp/entities/entities/glide_wheel/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; let HoverContents::Markup(markup) = hover.contents else { @@ -2365,7 +2511,7 @@ mod tests { "#, ))?; let file_id = ws.def_file("cityrp/entities/entities/glide_wheel/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; let HoverContents::Markup(markup) = hover.contents else { @@ -2437,7 +2583,7 @@ mod tests { "#, ))?; let file_id = ws.def_file("cityrp/entities/entities/glide_wheel/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; let HoverContents::Markup(markup) = hover.contents else { @@ -2493,7 +2639,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -2575,7 +2721,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -2620,7 +2766,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -2666,7 +2812,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -2709,7 +2855,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover") .or_fail()?; @@ -2750,7 +2896,7 @@ mod tests { let file_id = ws.def_file("gamemode/init.lua", &content); // When the hook is not registered, hover_gmod_hook_callback_function returns None and // the dispatch falls through to the generic keyword hover — always Some(...). - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected keyword fallback hover for unregistered hook") .or_fail()?; @@ -2799,7 +2945,7 @@ mod tests { "#, )?; let file_id = ws.def_file("gamemode/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover for return-only hook") .or_fail()?; @@ -2910,8 +3056,8 @@ mod tests { file_id: glua_code_analysis::FileId, position: lsp_types::Position, ) -> String { - let hover = - crate::handlers::hover::hover(&ws.analysis, file_id, position).expect("expected hover"); + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) + .expect("expected hover"); let HoverContents::Markup(markup) = hover.contents else { panic!("expected HoverContents::Markup"); }; @@ -3266,7 +3412,7 @@ mod tests { "#, )?; let file_id = ws.def_file("lua/autorun/server/init.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position); + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None); if let Some(hover) = hover { let HoverContents::Markup(markup) = hover.contents else { return Ok(()); diff --git a/crates/glua_ls/src/handlers/test/inlay_hint_test.rs b/crates/glua_ls/src/handlers/test/inlay_hint_test.rs index 4f62af54..1321d154 100644 --- a/crates/glua_ls/src/handlers/test/inlay_hint_test.rs +++ b/crates/glua_ls/src/handlers/test/inlay_hint_test.rs @@ -53,6 +53,36 @@ mod tests { Ok(()) } + #[gtest] + fn test_unannotated_param_with_gmod_name_hint() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + + check!(ws.check_inlay_hint( + r#" + ---@class Entity + + local function foo(ent) + local value = ent + end + "#, + vec![ + VirtualInlayHint { + label: ": Entity".to_string(), + line: 3, + pos: 38, + ref_file: Some("".to_string()), + }, + VirtualInlayHint { + label: ": Entity".to_string(), + line: 4, + pos: 31, + ref_file: Some("".to_string()), + }, + ] + )); + Ok(()) + } + #[gtest] fn test_local_hint_1() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); diff --git a/crates/glua_ls/src/handlers/test/legacy_module_test.rs b/crates/glua_ls/src/handlers/test/legacy_module_test.rs index 1359847e..dfd02289 100644 --- a/crates/glua_ls/src/handlers/test/legacy_module_test.rs +++ b/crates/glua_ls/src/handlers/test/legacy_module_test.rs @@ -43,7 +43,7 @@ local mod = includes "#, )?; let file_id = ws.def_file("consumer_bare.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover result for bare module name") .or_fail()?; @@ -92,7 +92,7 @@ includes.File("sv_init.lua") "#, )?; let file_id = ws.def_file("consumer_hover.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover result for includes.File") .or_fail()?; @@ -156,7 +156,7 @@ netstream.Send("chat", {}) "#, )?; let file_id = ws.def_file("consumer_netstream.lua", &content); - let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position, None) .ok_or("expected hover result for netstream.Send") .or_fail()?; diff --git a/crates/glua_ls/src/handlers/test/semantic_token_test.rs b/crates/glua_ls/src/handlers/test/semantic_token_test.rs index 8052a9df..01cc1548 100644 --- a/crates/glua_ls/src/handlers/test/semantic_token_test.rs +++ b/crates/glua_ls/src/handlers/test/semantic_token_test.rs @@ -61,6 +61,21 @@ mod tests { tokens.contains(&(line, col, len, token_type, modifiers)) } + fn has_token_type( + tokens: &[(u32, u32, u32, u32, u32)], + line: u32, + col: u32, + len: u32, + token_type: SemanticTokenType, + ) -> bool { + let token_type = token_type_index(token_type); + tokens + .iter() + .any(|(token_line, token_col, token_len, typ, _)| { + *token_line == line && *token_col == col && *token_len == len && *typ == token_type + }) + } + #[gtest] fn test_1() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -89,10 +104,11 @@ m.foo() let tokens = decode(&data); let namespace_idx = token_type_index(SemanticTokenType::NAMESPACE); - let method_idx = token_type_index(SemanticTokenType::METHOD); + let field_idx = token_type_index(CustomSemanticTokenType::FIELD); let readonly_declaration = modifier_bitset(&[ SemanticTokenModifier::READONLY, SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::LOCAL, ]); // `local m = require("mod")` @@ -110,7 +126,7 @@ m.foo() 1, 2, 3, - method_idx, + field_idx, modifier_bitset(&[CustomSemanticTokenModifier::CALLABLE]), ))), ] @@ -148,7 +164,23 @@ local x = 1 } #[gtest] - fn test_variable_and_builtin_tokens_follow_vscode_semantics() -> Result<()> { + fn test_string_literal_segments_use_utf16_lengths() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file("main.lua", "local s = \"😀\\n\"\n"); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + + verify_that!( + has_token(&tokens, 0, 10, 3, SemanticTokenType::STRING, &[]), + eq(true) + )?; + + Ok(()) + } + + #[gtest] + fn test_variable_and_unresolved_call_tokens_follow_vscode_semantics() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); let main = ws.def_file( "main.lua", @@ -205,6 +237,17 @@ print(global_var, x) ), eq(true) )?; + verify_that!( + has_token( + &tokens, + 3, + 0, + 5, + SemanticTokenType::FUNCTION, + &[CustomSemanticTokenModifier::CALLABLE], + ), + eq(true) + )?; verify_that!( has_token( &tokens, @@ -217,7 +260,7 @@ print(global_var, x) SemanticTokenModifier::READONLY, ], ), - eq(true) + eq(false) )?; verify_that!( has_token( @@ -281,42 +324,509 @@ end verify_that!( has_token( &tokens, - 1, - 4, - 2, - SemanticTokenType::FUNCTION, + 1, + 4, + 2, + SemanticTokenType::FUNCTION, + &[CustomSemanticTokenModifier::CALLABLE], + ), + eq(true) + )?; + verify_that!( + has_token(&tokens, 1, 7, 5, SemanticTokenType::PARAMETER, &[]), + eq(true) + )?; + + Ok(()) + } + + #[gtest] + fn test_unresolved_builtin_like_namespace_does_not_use_spelling_heuristic() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file( + "main.lua", + r#"print(string.lower("x")) +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + verify_that!( + has_token( + &tokens, + 0, + 6, + 6, + SemanticTokenType::NAMESPACE, + &[SemanticTokenModifier::DEFAULT_LIBRARY], + ), + eq(false) + )?; + + verify_that!( + has_token( + &tokens, + 0, + 13, + 5, + CustomSemanticTokenType::FIELD, + &[CustomSemanticTokenModifier::CALLABLE], + ), + eq(true) + )?; + + Ok(()) + } + + #[gtest] + fn test_doc_payload_tokens_keep_documentation_context() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file( + "main.lua", + r#"---@field callback fun() +---@realm server +---@namespace MyNS +---@using string +---@return string result +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + verify_that!( + has_token( + &tokens, + 0, + 10, + 8, + CustomSemanticTokenType::FIELD, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DOCUMENTATION, + ], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 1, + 10, + 6, + SemanticTokenType::ENUM_MEMBER, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DOCUMENTATION, + ], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 2, + 14, + 4, + SemanticTokenType::NAMESPACE, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DOCUMENTATION, + ], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 3, + 10, + 6, + SemanticTokenType::NAMESPACE, + &[SemanticTokenModifier::DOCUMENTATION], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 4, + 18, + 6, + SemanticTokenType::VARIABLE, + &[SemanticTokenModifier::DOCUMENTATION], + ), + eq(true) + )?; + + Ok(()) + } + + #[gtest] + fn test_unresolved_builtin_like_local_alias_does_not_use_spelling_heuristic() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file( + "main.lua", + r#"local str = string +str.lower("demo") +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + verify_that!( + has_token( + &tokens, + 0, + 6, + 3, + SemanticTokenType::NAMESPACE, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DEFAULT_LIBRARY, + CustomSemanticTokenModifier::LOCAL, + ], + ), + eq(false) + )?; + + verify_that!( + has_token( + &tokens, + 1, + 0, + 3, + SemanticTokenType::NAMESPACE, + &[ + SemanticTokenModifier::DEFAULT_LIBRARY, + CustomSemanticTokenModifier::LOCAL, + ], + ), + eq(false) + )?; + + Ok(()) + } + + #[gtest] + fn test_shadowed_builtin_namespace_alias_stays_local_variable() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file( + "main.lua", + r#"local string = {} +local str = string +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + verify_that!( + has_token( + &tokens, + 1, + 6, + 3, + SemanticTokenType::VARIABLE, + &[ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::LOCAL, + CustomSemanticTokenModifier::OBJECT, + ], + ), + eq(true) + )?; + + verify_that!( + has_token( + &tokens, + 1, + 6, + 3, + SemanticTokenType::NAMESPACE, + &[ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DEFAULT_LIBRARY, + CustomSemanticTokenModifier::LOCAL, + ], + ), + eq(false) + )?; + + Ok(()) + } + + #[gtest] + fn test_unresolved_gmod_realm_constants_do_not_use_spelling_heuristic() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file("main.lua", r#"print(CLIENT, SERVER, MENU_DLL)"#); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + + for (col, len) in [(6, 6), (14, 6), (22, 8)] { + verify_that!( + has_token( + &tokens, + 0, + col, + len, + SemanticTokenType::ENUM_MEMBER, + &[ + SemanticTokenModifier::DEFAULT_LIBRARY, + SemanticTokenModifier::READONLY, + ], + ), + eq(false) + )?; + } + + Ok(()) + } + + #[gtest] + fn test_callable_locals_stay_variables_but_function_declarations_stay_functions() -> Result<()> + { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file( + "main.lua", + r#"local function helper() +end +helper() + +local fn = function() end +fn() +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + + verify_that!( + has_token( + &tokens, + 0, + 15, + 6, + SemanticTokenType::FUNCTION, + &[SemanticTokenModifier::DECLARATION], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 2, + 0, + 6, + SemanticTokenType::FUNCTION, + &[CustomSemanticTokenModifier::CALLABLE], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 4, + 6, + 2, + SemanticTokenType::VARIABLE, + &[ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::LOCAL, + CustomSemanticTokenModifier::CALLABLE, + ], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 5, + 0, + 2, + SemanticTokenType::FUNCTION, + &[CustomSemanticTokenModifier::CALLABLE], + ), + eq(true) + )?; + + Ok(()) + } + + #[gtest] + fn test_callable_union_variable_keeps_lua_identity_and_callsite_signal() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file( + "main.lua", + r#"---@type fun()|nil +local maybeFn +maybeFn() +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + + verify_that!( + has_token( + &tokens, + 1, + 6, + 7, + SemanticTokenType::VARIABLE, + &[ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::LOCAL, + CustomSemanticTokenModifier::CALLABLE, + ], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 2, + 0, + 7, + SemanticTokenType::FUNCTION, + &[CustomSemanticTokenModifier::CALLABLE], + ), + eq(true) + )?; + + Ok(()) + } + + #[gtest] + fn test_table_fields_use_custom_field_token_type() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file( + "main.lua", + r#"local panel = {} +panel.headerPanel = 1 +print(panel.headerPanel) +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + + verify_that!( + has_token( + &tokens, + 1, + 6, + 11, + CustomSemanticTokenType::FIELD, + &[SemanticTokenModifier::MODIFICATION], + ), + eq(true) + )?; + verify_that!( + has_token(&tokens, 2, 12, 11, CustomSemanticTokenType::FIELD, &[],), + eq(true) + )?; + + Ok(()) + } + + #[gtest] + fn test_callable_table_fields_stay_fields_not_methods() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file( + "main.lua", + r#"local callbacks = { + onClick = function() end +} +callbacks.onClick() +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + verify_that!( + has_token( + &tokens, + 1, + 4, + 7, + CustomSemanticTokenType::FIELD, + &[ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::CALLABLE, + ], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 3, + 10, + 7, + CustomSemanticTokenType::FIELD, + &[CustomSemanticTokenModifier::CALLABLE], + ), + eq(true) + )?; + verify_that!( + has_token( + &tokens, + 3, + 10, + 7, + SemanticTokenType::METHOD, &[CustomSemanticTokenModifier::CALLABLE], ), - eq(true) - )?; - verify_that!( - has_token(&tokens, 1, 7, 5, SemanticTokenType::PARAMETER, &[]), - eq(true) + eq(false) )?; Ok(()) } #[gtest] - fn test_builtin_library_namespaces_are_not_plain_globals() -> Result<()> { + fn test_table_locals_and_index_prefixes_get_object_modifier() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); let main = ws.def_file( "main.lua", - r#"print(string.lower("x")) + r#"local Editor = {} +Editor.SAVE_DIR = "cityrp_glide_layouts/" +Editor.previewHookId = "Glide.VehicleLayoutEditorPreview" +Editor.sessions = Editor.sessions or {} "#, ); let data = ws.get_semantic_token_data_for_file(main)?; let tokens = decode(&data); + for (line, col) in [(1, 0), (2, 0), (3, 0), (3, 18)] { + verify_that!( + has_token( + &tokens, + line, + col, + 6, + SemanticTokenType::VARIABLE, + &[ + CustomSemanticTokenModifier::LOCAL, + CustomSemanticTokenModifier::OBJECT, + ], + ), + eq(true) + )?; + } + verify_that!( has_token( &tokens, 0, 6, 6, - SemanticTokenType::NAMESPACE, - &[SemanticTokenModifier::DEFAULT_LIBRARY], + SemanticTokenType::VARIABLE, + &[ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::LOCAL, + CustomSemanticTokenModifier::OBJECT, + ], ), eq(true) )?; @@ -324,12 +834,44 @@ end verify_that!( has_token( &tokens, - 0, + 1, + 7, + 8, + CustomSemanticTokenType::FIELD, + &[SemanticTokenModifier::MODIFICATION], + ), + eq(true) + )?; + + verify_that!( + has_token( + &tokens, + 2, + 7, 13, - 5, - SemanticTokenType::METHOD, - &[CustomSemanticTokenModifier::CALLABLE], + CustomSemanticTokenType::FIELD, + &[SemanticTokenModifier::MODIFICATION], + ), + eq(true) + )?; + + verify_that!( + has_token( + &tokens, + 1, + 7, + 8, + CustomSemanticTokenType::FIELD, + &[ + SemanticTokenModifier::READONLY, + SemanticTokenModifier::MODIFICATION, + ], ), + eq(false) + )?; + + verify_that!( + has_token(&tokens, 3, 25, 8, CustomSemanticTokenType::FIELD, &[]), eq(true) )?; @@ -337,17 +879,13 @@ end } #[gtest] - fn test_callable_locals_stay_variables_but_function_declarations_stay_functions() -> Result<()> - { + fn test_table_field_alias_keeps_local_object_signal() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); let main = ws.def_file( "main.lua", - r#"local function helper() -end -helper() - -local fn = function() end -fn() + r#"local StyledTheme = { colors = {} } +local colors = StyledTheme.colors +colors.primary = "white" "#, ); @@ -357,48 +895,42 @@ fn() verify_that!( has_token( &tokens, - 0, - 15, + 1, 6, - SemanticTokenType::FUNCTION, - &[SemanticTokenModifier::DECLARATION], + 6, + SemanticTokenType::VARIABLE, + &[ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::LOCAL, + CustomSemanticTokenModifier::OBJECT, + ], ), eq(true) )?; + verify_that!( has_token( &tokens, 2, 0, 6, - SemanticTokenType::FUNCTION, - &[CustomSemanticTokenModifier::CALLABLE], - ), - eq(true) - )?; - verify_that!( - has_token( - &tokens, - 4, - 6, - 2, SemanticTokenType::VARIABLE, &[ - SemanticTokenModifier::DECLARATION, CustomSemanticTokenModifier::LOCAL, - CustomSemanticTokenModifier::CALLABLE, + CustomSemanticTokenModifier::OBJECT, ], ), eq(true) )?; + verify_that!( has_token( &tokens, - 5, - 0, 2, - SemanticTokenType::FUNCTION, - &[CustomSemanticTokenModifier::CALLABLE], + 7, + 7, + CustomSemanticTokenType::FIELD, + &[SemanticTokenModifier::MODIFICATION], ), eq(true) )?; @@ -407,13 +939,15 @@ fn() } #[gtest] - fn test_callable_union_variable_keeps_lua_identity_and_callsite_signal() -> Result<()> { + fn test_local_class_instances_stay_variables_with_object_modifier() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); let main = ws.def_file( "main.lua", - r#"---@type fun()|nil -local maybeFn -maybeFn() + r#"---@class DPanel +---@return DPanel +local function create() end + +local pnl = create() "#, ); @@ -423,41 +957,42 @@ maybeFn() verify_that!( has_token( &tokens, - 1, + 4, 6, - 7, + 3, SemanticTokenType::VARIABLE, &[ SemanticTokenModifier::DECLARATION, CustomSemanticTokenModifier::LOCAL, - CustomSemanticTokenModifier::CALLABLE, + CustomSemanticTokenModifier::OBJECT, ], ), eq(true) )?; + verify_that!( - has_token( - &tokens, - 2, - 0, - 7, - SemanticTokenType::FUNCTION, - &[CustomSemanticTokenModifier::CALLABLE], - ), - eq(true) + tokens.iter().any(|(line, col, len, token_type, _)| { + *line == 4 + && *col == 6 + && *len == 3 + && *token_type == token_type_index(SemanticTokenType::CLASS) + }), + eq(false) )?; Ok(()) } #[gtest] - fn test_table_fields_use_custom_field_token_type() -> Result<()> { + fn test_local_class_alias_keeps_class_and_local_signal() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); let main = ws.def_file( "main.lua", - r#"local panel = {} -panel.headerPanel = 1 -print(panel.headerPanel) + r#"---@class Glide.VehicleLayoutEditor +Glide = {} +Glide.VehicleLayoutEditor = {} + +local Editor = Glide.VehicleLayoutEditor "#, ); @@ -467,32 +1002,30 @@ print(panel.headerPanel) verify_that!( has_token( &tokens, - 1, + 4, 6, - 11, - CustomSemanticTokenType::FIELD, - &[SemanticTokenModifier::MODIFICATION], + 6, + SemanticTokenType::CLASS, + &[ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::LOCAL, + ], ), eq(true) )?; - verify_that!( - has_token(&tokens, 2, 12, 11, CustomSemanticTokenType::FIELD, &[],), - eq(true) - )?; Ok(()) } #[gtest] - fn test_local_class_instances_stay_variables_with_object_modifier() -> Result<()> { + fn test_shadowed_class_path_alias_stays_local_variable() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); let main = ws.def_file( "main.lua", - r#"---@class DPanel ----@return DPanel -local function create() end + r#"---@class Glide.VehicleLayoutEditor +local Glide = { VehicleLayoutEditor = 1 } -local pnl = create() +local Editor = Glide.VehicleLayoutEditor "#, ); @@ -502,26 +1035,30 @@ local pnl = create() verify_that!( has_token( &tokens, - 4, - 6, 3, + 6, + 6, SemanticTokenType::VARIABLE, &[ SemanticTokenModifier::DECLARATION, CustomSemanticTokenModifier::LOCAL, - CustomSemanticTokenModifier::OBJECT, ], ), eq(true) )?; verify_that!( - tokens.iter().any(|(line, col, len, token_type, _)| { - *line == 4 - && *col == 6 - && *len == 3 - && *token_type == token_type_index(SemanticTokenType::CLASS) - }), + has_token( + &tokens, + 3, + 6, + 6, + SemanticTokenType::CLASS, + &[ + SemanticTokenModifier::DECLARATION, + CustomSemanticTokenModifier::LOCAL, + ], + ), eq(false) )?; @@ -529,11 +1066,12 @@ local pnl = create() } #[gtest] - fn test_hook_name_strings_use_event_tokens() -> Result<()> { + fn test_hook_name_strings_do_not_use_call_path_heuristic() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); let main = ws.def_file( "main.lua", - r#"hook.Add("Think", "demo", function() end) + r#"local hook = { Add = function() end, Run = function() end } +hook.Add("Think", "demo", function() end) hook.Run("Think") "#, ); @@ -542,11 +1080,43 @@ hook.Run("Think") let tokens = decode(&data); verify_that!( - has_token(&tokens, 0, 9, 7, SemanticTokenType::EVENT, &[]), + has_token(&tokens, 1, 9, 7, SemanticTokenType::EVENT, &[]), + eq(false) + )?; + verify_that!( + has_token(&tokens, 2, 9, 7, SemanticTokenType::EVENT, &[]), + eq(false) + )?; + + Ok(()) + } + + #[gtest] + fn test_labels_and_goto_use_label_tokens() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let main = ws.def_file( + "main.lua", + r#"::done:: +goto done +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + + verify_that!( + has_token( + &tokens, + 0, + 2, + 4, + CustomSemanticTokenType::LABEL, + &[SemanticTokenModifier::DECLARATION], + ), eq(true) )?; verify_that!( - has_token(&tokens, 1, 9, 7, SemanticTokenType::EVENT, &[]), + has_token(&tokens, 1, 5, 4, CustomSemanticTokenType::LABEL, &[]), eq(true) )?; @@ -576,12 +1146,42 @@ end let tokens = decode(&data); verify_that!( - has_token(&tokens, 0, 0, 3, SemanticTokenType::CLASS, &[]), + has_token_type(&tokens, 0, 0, 3, SemanticTokenType::CLASS), eq(true) )?; + + Ok(()) + } + + #[gtest] + fn test_local_shadow_of_scoped_gmod_class_global_stays_local_variable() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = ws.get_emmyrc(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.scripted_class_scopes.include = + vec![EmmyrcGmodScriptedClassScopeEntry::LegacyGlob( + "entities/**".to_string(), + )]; + ws.update_emmyrc(emmyrc); + + let main = ws.def_file( + "lua/entities/test_entity/shared.lua", + r#"local ENT = {} +ENT.Type = "anim" +"#, + ); + + let data = ws.get_semantic_token_data_for_file(main)?; + let tokens = decode(&data); + + verify_that!( + has_token_type(&tokens, 0, 6, 3, SemanticTokenType::CLASS), + eq(false) + )?; + verify_that!( - has_token(&tokens, 1, 9, 3, SemanticTokenType::CLASS, &[]), - eq(true) + has_token_type(&tokens, 1, 0, 3, SemanticTokenType::CLASS), + eq(false) )?; Ok(()) @@ -752,7 +1352,7 @@ function cityrp.vehicle.drive() end SemanticTokenType::NAMESPACE, &[CustomSemanticTokenModifier::GLOBAL,] ), - eq(true) + eq(false) )?; verify_that!( @@ -809,7 +1409,8 @@ my_table.first.second = 1 SemanticTokenType::VARIABLE, &[ SemanticTokenModifier::DECLARATION, - CustomSemanticTokenModifier::LOCAL + CustomSemanticTokenModifier::LOCAL, + CustomSemanticTokenModifier::OBJECT ] ), eq(true) @@ -860,7 +1461,7 @@ my_table.first.second = 1 } #[gtest] - fn test_single_method_global_member_stays_property() -> Result<()> { + fn test_global_table_method_owner_is_namespace_like() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); let main = ws.def_file( "main.lua", @@ -873,14 +1474,7 @@ function my_global.action() end // my_global verify_that!( - has_token( - &tokens, - 1, - 9, - 9, - SemanticTokenType::NAMESPACE, - &[CustomSemanticTokenModifier::GLOBAL] - ), + has_token(&tokens, 1, 9, 9, SemanticTokenType::NAMESPACE, &[]), eq(true) )?; diff --git a/crates/glua_ls/src/handlers/test_lib/mod.rs b/crates/glua_ls/src/handlers/test_lib/mod.rs index 8f242431..1aa191c1 100644 --- a/crates/glua_ls/src/handlers/test_lib/mod.rs +++ b/crates/glua_ls/src/handlers/test_lib/mod.rs @@ -1,4 +1,4 @@ -use glua_code_analysis::{EmmyLuaAnalysis, Emmyrc, FileId, VirtualUrlGenerator}; +use glua_code_analysis::{EmmyLuaAnalysis, Emmyrc, FileId, RenderLevel, VirtualUrlGenerator}; use googletest::prelude::*; use itertools::Itertools; use lsp_types::{ @@ -224,9 +224,18 @@ impl ProviderVirtualWorkspace { } pub fn check_hover(&mut self, block_str: &str, expected: VirtualHoverResult) -> Result<()> { + self.check_hover_with_level(block_str, expected, None) + } + + pub fn check_hover_with_level( + &mut self, + block_str: &str, + expected: VirtualHoverResult, + render_level: Option, + ) -> Result<()> { let (content, position) = Self::handle_file_content(block_str)?; let file_id = self.def(&content); - let result = hover(&self.analysis, file_id, position) + let result = hover(&self.analysis, file_id, position, render_level) .ok_or("couldn't get a hover") .or_fail()?; let Hover { contents, range } = result; diff --git a/crates/glua_ls/src/server/lsp_server.rs b/crates/glua_ls/src/server/lsp_server.rs index 0950a4cf..0f79131f 100644 --- a/crates/glua_ls/src/server/lsp_server.rs +++ b/crates/glua_ls/src/server/lsp_server.rs @@ -149,6 +149,7 @@ fn should_fail_fast_request_during_init(method: &str) -> bool { | "gluals/gmodScriptedClasses" | "gluals/gmodScriptedClassesV2" | "gluals/docSearch" + | "gluals/hoverExpand" | "emmy/annotator" ) } diff --git a/crates/glua_ls/src/util/long_running_watchdog.rs b/crates/glua_ls/src/util/long_running_watchdog.rs new file mode 100644 index 00000000..aa2d8e0e --- /dev/null +++ b/crates/glua_ls/src/util/long_running_watchdog.rs @@ -0,0 +1,186 @@ +use std::{ + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +const WATCHDOG_POLL_INTERVAL: Duration = Duration::from_secs(5); +const FIRST_LOG_AFTER: Duration = Duration::from_secs(15); +const SECOND_LOG_AFTER: Duration = Duration::from_secs(30); +const REPEATED_LOG_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone)] +struct LongRunningWatchdogSnapshot { + phase: String, + completed: Option, + total: Option, +} + +impl LongRunningWatchdogSnapshot { + fn new(phase: impl Into) -> Self { + Self { + phase: phase.into(), + completed: None, + total: None, + } + } + + fn describe(&self) -> String { + match (self.completed, self.total) { + (Some(completed), Some(total)) if total > 0 => { + let percent = completed.saturating_mul(100) / total; + format!( + "{} ({} / {} complete, {}%)", + self.phase, completed, total, percent + ) + } + (Some(completed), Some(total)) => { + format!("{} ({} / {} complete)", self.phase, completed, total) + } + _ => self.phase.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct LongRunningWatchdogStatus { + snapshot: Arc>, +} + +impl LongRunningWatchdogStatus { + pub fn new(phase: impl Into) -> Self { + Self { + snapshot: Arc::new(Mutex::new(LongRunningWatchdogSnapshot::new(phase))), + } + } + + pub fn set_phase(&self, phase: impl Into) { + self.update(|snapshot| { + snapshot.phase = phase.into(); + snapshot.completed = None; + snapshot.total = None; + }); + } + + pub fn set_progress(&self, phase: impl Into, completed: usize, total: usize) { + self.update(|snapshot| { + snapshot.phase = phase.into(); + snapshot.completed = Some(completed); + snapshot.total = Some(total); + }); + } + + fn describe(&self) -> String { + self.snapshot + .lock() + .map(|snapshot| snapshot.describe()) + .unwrap_or_else(|_| "status unavailable".to_string()) + } + + fn update(&self, update: impl FnOnce(&mut LongRunningWatchdogSnapshot)) { + if let Ok(mut snapshot) = self.snapshot.lock() { + update(&mut snapshot); + } + } +} + +#[derive(Debug)] +pub struct LongRunningLogTicker { + next_log_after: Duration, +} + +impl Default for LongRunningLogTicker { + fn default() -> Self { + Self { + next_log_after: FIRST_LOG_AFTER, + } + } +} + +impl LongRunningLogTicker { + pub fn should_log(&mut self, elapsed: Duration) -> bool { + if elapsed < self.next_log_after { + return false; + } + + self.next_log_after = match self.next_log_after { + FIRST_LOG_AFTER => SECOND_LOG_AFTER, + SECOND_LOG_AFTER => SECOND_LOG_AFTER + REPEATED_LOG_INTERVAL / 2, + next => next + REPEATED_LOG_INTERVAL, + }; + + true + } +} + +#[derive(Debug)] +pub struct LongRunningWatchdogGuard { + cancel_token: CancellationToken, + _handle: JoinHandle<()>, +} + +impl Drop for LongRunningWatchdogGuard { + fn drop(&mut self) { + self.cancel_token.cancel(); + } +} + +pub fn spawn_long_running_watchdog( + task_name: &'static str, + status: LongRunningWatchdogStatus, +) -> LongRunningWatchdogGuard { + let cancel_token = CancellationToken::new(); + let task_cancel_token = cancel_token.clone(); + let started_at = Instant::now(); + + let handle = tokio::spawn(async move { + let mut ticker = LongRunningLogTicker::default(); + + loop { + tokio::select! { + _ = task_cancel_token.cancelled() => break, + _ = tokio::time::sleep(WATCHDOG_POLL_INTERVAL) => { + let elapsed = started_at.elapsed(); + if ticker.should_log(elapsed) { + log::warn!( + "{} still running after {}s: {}", + task_name, + elapsed.as_secs(), + status.describe() + ); + } + } + } + } + }); + + LongRunningWatchdogGuard { + cancel_token, + _handle: handle, + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use googletest::prelude::*; + + use super::LongRunningLogTicker; + + #[gtest] + fn long_running_log_ticker_uses_escalating_startup_cadence() { + let mut ticker = LongRunningLogTicker::default(); + + expect_that!(ticker.should_log(Duration::from_secs(14)), eq(false)); + expect_that!(ticker.should_log(Duration::from_secs(15)), eq(true)); + expect_that!(ticker.should_log(Duration::from_secs(20)), eq(false)); + expect_that!(ticker.should_log(Duration::from_secs(30)), eq(true)); + expect_that!(ticker.should_log(Duration::from_secs(59)), eq(false)); + expect_that!(ticker.should_log(Duration::from_secs(60)), eq(true)); + expect_that!(ticker.should_log(Duration::from_secs(119)), eq(false)); + expect_that!(ticker.should_log(Duration::from_secs(120)), eq(true)); + } +} diff --git a/crates/glua_ls/src/util/mod.rs b/crates/glua_ls/src/util/mod.rs index ddc8bfbf..3729a05e 100644 --- a/crates/glua_ls/src/util/mod.rs +++ b/crates/glua_ls/src/util/mod.rs @@ -1,8 +1,10 @@ mod desc; +mod long_running_watchdog; mod module_name_convert; mod time_cancel_token; pub use desc::*; +pub use long_running_watchdog::*; pub use module_name_convert::{ file_name_convert, module_name_convert, to_camel_case, to_pascal_case, to_snake_case, }; diff --git a/crates/glua_parser/src/syntax/node/lua/expr.rs b/crates/glua_parser/src/syntax/node/lua/expr.rs index 7266cdae..172566a2 100644 --- a/crates/glua_parser/src/syntax/node/lua/expr.rs +++ b/crates/glua_parser/src/syntax/node/lua/expr.rs @@ -521,6 +521,51 @@ impl LuaTableExpr { pub fn get_fields(&self) -> LuaAstChildren { self.children() } + + /// Maximum number of positional rows a sequential ("array-style") table + /// literal may have while still being materialized with per-index members + /// and rich shape. Beyond this the literal is summarized as an array + /// (`T[]`) to keep inference/hover cheap on very large literals. + pub const SHAPED_ARRAY_LITERAL_LIMIT: usize = 50; + + /// Whether this is a sequential ("array-style") table literal whose entries + /// are themselves table literals — e.g. + /// `{ { offset = .. }, { offset = .. } }`. + /// + /// Such literals carry meaningful per-row shape, so they are materialized as + /// a dynamic [`crate::LuaSyntaxKind::TableArrayExpr`]-backed table (with + /// integer-keyed members `[1]`, `[2]`, ...) rather than collapsed to a bare + /// `table`. Simple scalar arrays (`{ 1, 2, 3 }`) intentionally do NOT match, + /// so they stay summarized as `T[]`. + /// + /// This is a purely syntactic check so the declaration analyzer (which + /// registers members) and the inference pass (which assigns the type) make + /// the same decision without needing inferred element types. + pub fn is_shaped_array_literal(&self) -> bool { + if !self.is_array() { + return false; + } + + let mut count = 0usize; + let mut has_table_row = false; + for field in self.get_fields() { + // Variadic spreads (`{ f() }` where the last value is `...`) cannot + // have a fixed member set; bail out to the array summary path. + let Some(value_expr) = field.get_value_expr() else { + return false; + }; + if !matches!(value_expr, LuaExpr::TableExpr(_)) { + return false; + } + has_table_row = true; + count += 1; + if count > Self::SHAPED_ARRAY_LITERAL_LIMIT { + return false; + } + } + + has_table_row + } } impl From for LuaSingleArgExpr { diff --git a/docs/mintlify/configuration/hints-and-hover.mdx b/docs/mintlify/configuration/hints-and-hover.mdx index 3b8088c9..90814d3c 100644 --- a/docs/mintlify/configuration/hints-and-hover.mdx +++ b/docs/mintlify/configuration/hints-and-hover.mdx @@ -65,13 +65,13 @@ Inline values show variable contents during a debug session, overlaid on the sou | Option | Type | Default | Description | |---|---|---|---| | `hover.enable` | `boolean` | `true` | Enable hover documentation | -| `hover.customDetail` | `integer \| null` | `null` | Detail level (0–255). `null` uses the default level | Hover documentation shows: - Type signatures and docs for functions, fields, and classes -- Realm badge (client/server/shared) for GMod API symbols +- Realm badge (client/shared/server) for GMod API symbols - `@deprecated` messages for deprecated symbols - `@see` cross-references +- Inline **+**/**−** verbosity buttons for classes with many members — click **+** to show more members, **−** to show fewer ---