From b15f24461a34782297330219949cd6c1ec320c04 Mon Sep 17 00:00:00 2001 From: ViTeXFTW Date: Tue, 1 Sep 2026 20:09:25 +0200 Subject: [PATCH 1/2] feat: increment suggested module tags --- crates/analysis/src/completion.rs | 75 ++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index ccdb8c5..a9cb26c 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -71,7 +71,10 @@ pub fn complete( index, (file, offset), ), - PosContext::ModuleName { slot_accepts } => module_name_completions(analyzer, &slot_accepts), + PosContext::ModuleName { + scope_node, + slot_accepts, + } => module_name_completions(analyzer, &scope_node, &slot_accepts), PosContext::SubBlockArg { argument_type } => { completions_for_type(analyzer, &argument_type, 0, None, None, index) } @@ -93,8 +96,10 @@ enum PosContext { first_token: Option, }, /// Completing a module type name after a slot `=`. Carries the slot's - /// accepted interfaces so completions can be filtered to valid modules only. + /// enclosing scope so its snippet can choose an unused-looking module tag, + /// and accepted interfaces so candidates can be filtered to valid modules. ModuleName { + scope_node: SyntaxNode, slot_accepts: Vec, }, /// Completing the argument of a sub-block header. @@ -189,7 +194,8 @@ fn classify_position(analyzer: &Analyzer, root: &SyntaxNode, offset: u32) -> Pos // (before any nested field/scope) and after `=`, and the slot is a real // module slot of the parent block. if on_header_line(&module_node, offset) && after_equals(&module_node, offset) { - let parent = enclosing_scope(&module_node).map(|p| scope_schema(analyzer, &p)); + let scope_node = enclosing_scope(&module_node); + let parent = scope_node.as_ref().map(|p| scope_schema(analyzer, p)); let slot = Module(module_node.clone()).slot(); let slot_accepts = slot.as_ref().and_then(|s| { parent.as_ref().and_then(|p| { @@ -201,6 +207,7 @@ fn classify_position(analyzer: &Analyzer, root: &SyntaxNode, offset: u32) -> Pos }); if let Some(accepts) = slot_accepts { return PosContext::ModuleName { + scope_node: scope_node.unwrap_or_else(|| root.clone()), slot_accepts: accepts, }; } @@ -790,7 +797,12 @@ fn top_level_completions(analyzer: &Analyzer) -> Vec { .collect() } -fn module_name_completions(analyzer: &Analyzer, slot_accepts: &[String]) -> Vec { +fn module_name_completions( + analyzer: &Analyzer, + scope_node: &SyntaxNode, + slot_accepts: &[String], +) -> Vec { + let tag = next_module_tag(analyzer, scope_node); analyzer .schema() .modules @@ -801,7 +813,7 @@ fn module_name_completions(analyzer: &Analyzer, slot_accepts: &[String]) -> Vec< .map(|m| { // Snippet: module name + placeholder tag + indented body + End. // Also satisfies missing-module-tag in one accept. - let insert = Some(format!("{} ${{1:ModuleTag_01}}\n\t$0\nEnd", m.name)); + let insert = Some(format!("{} ${{1:{tag}}}\n\t$0\nEnd", m.name)); Completion { label: m.name.clone(), kind: CompletionKind::Module, @@ -812,6 +824,37 @@ fn module_name_completions(analyzer: &Analyzer, slot_accepts: &[String]) -> Vec< .collect() } +/// Suggest the next numeric tag used by direct module slots in this scope. +/// +/// Descriptive tags (such as `ModuleTag_Draw`) deliberately do not affect the +/// numeric sequence. Only genuine module slots are considered: sub-block +/// headers can also have several arguments, but those arguments are not tags. +fn next_module_tag(analyzer: &Analyzer, scope_node: &SyntaxNode) -> String { + const PREFIX: &str = "ModuleTag_"; + let module_slots = scope_schema(analyzer, scope_node).module_slots(); + let highest = scope_node + .children() + .filter_map(Module::cast) + .filter(|module| { + module.slot().is_some_and(|slot| { + module_slots + .iter() + .any(|module_slot| module_slot.keyword.eq_ignore_ascii_case(slot.text())) + }) + }) + .filter_map(|module| module.tag()) + .filter_map(|tag| { + let text = tag.text(); + text.get(..PREFIX.len()) + .filter(|prefix| prefix.eq_ignore_ascii_case(PREFIX)) + .and_then(|_| text.get(PREFIX.len()..)) + .and_then(|number| number.parse::().ok()) + }) + .max() + .unwrap_or(0); + format!("ModuleTag_{:02}", highest.saturating_add(1)) +} + // --- position helpers --- fn ancestor_of_kind(node: &SyntaxNode, kind: SyntaxKind) -> Option { @@ -1007,6 +1050,28 @@ mod tests { assert!(out.contains(&"ActiveBody".to_string()), "{out:?}"); } + #[test] + fn module_snippet_uses_next_numeric_tag_in_object() { + let src = "Object Tank\n Draw = W3DTankDraw ModuleTag_01\n End\n Behavior = SlowDeathBehavior MODULETAG_03\n End\n Behavior = \nEnd\n"; + let offset = "Object Tank\n Draw = W3DTankDraw ModuleTag_01\n End\n Behavior = SlowDeathBehavior MODULETAG_03\n End\n Behavior = ".len() as u32; + let completion = item(src, offset, "AutoHealBehavior"); + assert_eq!( + completion.insert.as_deref(), + Some("AutoHealBehavior ${1:ModuleTag_04}\n\t$0\nEnd") + ); + } + + #[test] + fn module_snippet_ignores_descriptive_tags_and_sub_block_arguments() { + let src = "Object Tank\n Draw = W3DTankDraw ModuleTag_Draw\n ConditionState = DAMAGED REALLYDAMAGED\n End\n End\n Behavior = \nEnd\n"; + let offset = "Object Tank\n Draw = W3DTankDraw ModuleTag_Draw\n ConditionState = DAMAGED REALLYDAMAGED\n End\n End\n Behavior = ".len() as u32; + let completion = item(src, offset, "AutoHealBehavior"); + assert_eq!( + completion.insert.as_deref(), + Some("AutoHealBehavior ${1:ModuleTag_01}\n\t$0\nEnd") + ); + } + #[test] fn model_asset_completions_use_index() { let a = Analyzer::embedded(); From 542b1e2c0c91598beabc232038406b9c5a7cee43 Mon Sep 17 00:00:00 2001 From: ViTeXFTW Date: Tue, 1 Sep 2026 20:14:23 +0200 Subject: [PATCH 2/2] fix: include nested module tags in suggestions --- crates/analysis/src/completion.rs | 48 +++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index a9cb26c..2c3965b 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -824,18 +824,30 @@ fn module_name_completions( .collect() } -/// Suggest the next numeric tag used by direct module slots in this scope. +/// Suggest the next numeric tag used by module slots in the enclosing Object. /// /// Descriptive tags (such as `ModuleTag_Draw`) deliberately do not affect the /// numeric sequence. Only genuine module slots are considered: sub-block /// headers can also have several arguments, but those arguments are not tags. fn next_module_tag(analyzer: &Analyzer, scope_node: &SyntaxNode) -> String { const PREFIX: &str = "ModuleTag_"; - let module_slots = scope_schema(analyzer, scope_node).module_slots(); - let highest = scope_node - .children() + let object_node = scope_node + .ancestors() + .find(|node| { + Block(node.clone()) + .keyword() + .is_some_and(|keyword| keyword.text().eq_ignore_ascii_case("Object")) + }) + .unwrap_or_else(|| scope_node.clone()); + let highest = object_node + .descendants() .filter_map(Module::cast) .filter(|module| { + let parent = enclosing_scope(&module.0); + let module_slots = parent + .as_ref() + .map(|parent| scope_schema(analyzer, parent).module_slots()) + .unwrap_or_default(); module.slot().is_some_and(|slot| { module_slots .iter() @@ -848,11 +860,11 @@ fn next_module_tag(analyzer: &Analyzer, scope_node: &SyntaxNode) -> String { text.get(..PREFIX.len()) .filter(|prefix| prefix.eq_ignore_ascii_case(PREFIX)) .and_then(|_| text.get(PREFIX.len()..)) - .and_then(|number| number.parse::().ok()) + .and_then(|number| number.parse::().ok()) }) .max() .unwrap_or(0); - format!("ModuleTag_{:02}", highest.saturating_add(1)) + format!("ModuleTag_{:02}", highest + 1) } // --- position helpers --- @@ -1072,6 +1084,30 @@ mod tests { ); } + #[test] + fn module_snippet_in_reentrant_scope_uses_object_wide_sequence() { + let src = "Object Tank\n AddModule\n Behavior = SlowDeathBehavior ModuleTag_04\n End\n Behavior = \n End\nEnd\n"; + let offset = "Object Tank\n AddModule\n Behavior = SlowDeathBehavior ModuleTag_04\n End\n Behavior = ".len() as u32; + let completion = item(src, offset, "AutoHealBehavior"); + assert_eq!( + completion.insert.as_deref(), + Some("AutoHealBehavior ${1:ModuleTag_05}\n\t$0\nEnd") + ); + } + + #[test] + fn module_snippet_continues_past_u32_tag_values() { + let src = "Object Tank\n Behavior = SlowDeathBehavior ModuleTag_4294967295\n End\n Behavior = \nEnd\n"; + let offset = + "Object Tank\n Behavior = SlowDeathBehavior ModuleTag_4294967295\n End\n Behavior = " + .len() as u32; + let completion = item(src, offset, "AutoHealBehavior"); + assert_eq!( + completion.insert.as_deref(), + Some("AutoHealBehavior ${1:ModuleTag_4294967296}\n\t$0\nEnd") + ); + } + #[test] fn model_asset_completions_use_index() { let a = Analyzer::embedded();