From 9d8e6298363dd4122f52560f6066a70e9a20ea53 Mon Sep 17 00:00:00 2001 From: ViTeXFTW Date: Sat, 5 Sep 2026 10:09:34 +0200 Subject: [PATCH 1/2] feat: preview module definitions in tag completions --- crates/analysis/src/completion.rs | 42 +++++++++++++++++++++++++++- crates/analysis/src/diagnostics.rs | 2 +- crates/analysis/src/index.rs | 45 ++++++++++++++++++++++++++---- crates/server/src/convert.rs | 28 +++++++++++++++++++ crates/server/tests/e2e.py | 17 +++++++++++ 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index 2c3965b..e90e460 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -35,6 +35,8 @@ pub struct Completion { pub label: String, pub kind: CompletionKind, pub detail: Option, + /// Optional Markdown shown by an LSP client alongside the selected item. + pub documentation: Option, /// Optional LSP snippet string. When the client supports snippets, the /// server uses this as `insertText` with `InsertTextFormat::SNIPPET` /// instead of the plain `label`. `$0` marks the final cursor position; @@ -270,6 +272,7 @@ fn field_key_completions(analyzer: &Analyzer, scope_node: &SyntaxNode) -> Vec Vec Vec".into(), kind: CompletionKind::Value, detail: Some(format!("{} tokens", tokens.len())), + documentation: None, insert: Some(snippet), }, ); @@ -636,18 +647,21 @@ fn completions_for_type( label: "R: G: B:".into(), kind: CompletionKind::Value, detail: Some("color".into()), + documentation: None, insert: Some("R:${1:255} G:${2:255} B:${3:255}".into()), }], ValueType::Coord2D => vec![Completion { label: "X: Y:".into(), kind: CompletionKind::Value, detail: Some("2D coordinate".into()), + documentation: None, insert: Some("X:${1:0} Y:${2:0}".into()), }], ValueType::Coord3D => vec![Completion { label: "X: Y: Z:".into(), kind: CompletionKind::Value, detail: Some("3D coordinate".into()), + documentation: None, insert: Some("X:${1:0} Y:${2:0} Z:${3:0}".into()), }], ValueType::Bool => ["Yes", "No"] @@ -656,6 +670,7 @@ fn completions_for_type( label: v.to_string(), kind: CompletionKind::Value, detail: None, + documentation: None, insert: None, }) .collect(), @@ -668,6 +683,7 @@ fn completions_for_type( label: m.name.clone(), kind: CompletionKind::EnumMember, detail: Some(value_set.clone()), + documentation: None, insert: None, }) .collect() @@ -681,6 +697,7 @@ fn completions_for_type( label: n.to_string(), kind: CompletionKind::Reference, detail: Some(format!("{ref_kind:?}")), + documentation: None, insert: None, }) .collect() @@ -692,6 +709,7 @@ fn completions_for_type( label: n.to_string(), kind: CompletionKind::Reference, detail: Some(format!("{ref_kind:?} (engine builtin)")), + documentation: None, insert: None, })); out @@ -768,6 +786,7 @@ fn asset_completions( label, kind: CompletionKind::Reference, detail: Some(detail.to_string()), + documentation: None, insert: None, }) .collect() @@ -791,6 +810,7 @@ fn top_level_completions(analyzer: &Analyzer) -> Vec { label: b.name.clone(), kind: CompletionKind::Block, detail: Some("block".into()), + documentation: None, insert, } }) @@ -818,6 +838,7 @@ fn module_name_completions( label: m.name.clone(), kind: CompletionKind::Module, detail: Some("module".into()), + documentation: None, insert, } }) @@ -1045,6 +1066,25 @@ mod tests { assert!(!out.iter().any(|item| item.label == "ModuleTag_Later")); } + #[test] + fn remove_module_completion_documents_the_defining_module() { + let a = Analyzer::embedded(); + let defs = + a.parse("Object Tank\n Behavior = PhysicsBehavior ModuleTag_Physics\n End\nEnd\n"); + let mut index = WorkspaceIndex::new(); + index.set_file_tags("base.ini", crate::index::module_tags_in(&a, &defs)); + let src = "Object Tank\n RemoveModule \nEnd\n"; + let offset = "Object Tank\n RemoveModule ".len() as u32; + let item = complete(&a, &a.parse(src), offset, Some(&index), Some("map.ini")) + .into_iter() + .find(|item| item.label == "ModuleTag_Physics") + .expect("module tag completion"); + assert_eq!( + item.documentation.as_deref(), + Some("```ini\nBehavior = PhysicsBehavior ModuleTag_Physics\n```") + ); + } + #[test] fn enum_value_suggests_members() { let src = "Weapon AK47\n DeathType = \nEnd\n"; diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index b6e3a70..edf5ce6 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -1069,7 +1069,7 @@ impl<'a> Ctx<'a> { Some(tag.text_range().start().into()), ) .iter() - .any(|known| known.eq_ignore_ascii_case(tag_name)) + .any(|known| known.name.eq_ignore_ascii_case(tag_name)) { self.error( &tag, diff --git a/crates/analysis/src/index.rs b/crates/analysis/src/index.rs index d035d4a..b679b84 100644 --- a/crates/analysis/src/index.rs +++ b/crates/analysis/src/index.rs @@ -64,6 +64,10 @@ pub struct ModuleTagDefinition { pub object: String, pub name: String, pub span: Span, + /// The defining module header, shown when this tag is offered as a + /// RemoveModule completion. + #[serde(default)] + pub snippet: String, #[serde(default)] pub is_reference: bool, } @@ -86,6 +90,14 @@ struct NameEntry { struct ModuleTagEntry { name: String, location: Location, + snippet: String, +} + +/// A module tag that is valid at a `RemoveModule`/`ReplaceModule` site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EffectiveModuleTag<'a> { + pub name: &'a str, + pub snippet: &'a str, } /// Workspace-wide symbol table, grouped by reference kind then name. @@ -574,6 +586,7 @@ impl WorkspaceIndex { .push(ModuleTagEntry { name: tag.name, location, + snippet: tag.snippet, }); } } @@ -648,7 +661,7 @@ impl WorkspaceIndex { name: &str, file: Option<&str>, before: Option, - ) -> Vec<&'a str> { + ) -> Vec> { let mut out = self .object_tags .get(&name.to_ascii_lowercase()) @@ -659,13 +672,25 @@ impl WorkspaceIndex { tag.location.file == file && tag.location.span.start >= before }) }) - .map(|tag| tag.name.as_str()) + .map(|tag| EffectiveModuleTag { + name: tag.name.as_str(), + snippet: tag.snippet.as_str(), + }) .collect::>(); let is_new_override = file.is_some_and(|file| self.is_new_override_object(name, file)); if is_new_override { - out.extend(self.module_tags_for_object("DefaultThingTemplate")); + out.extend( + self.object_tags + .get("defaultthingtemplate") + .into_iter() + .flatten() + .map(|tag| EffectiveModuleTag { + name: tag.name.as_str(), + snippet: tag.snippet.as_str(), + }), + ); let mut seen = std::collections::HashSet::new(); - out.retain(|tag| seen.insert(tag.to_ascii_lowercase())); + out.retain(|tag| seen.insert(tag.name.to_ascii_lowercase())); } out } @@ -1132,11 +1157,19 @@ pub fn module_tags_in(_analyzer: &Analyzer, parse: &Parse) -> Vec { + format!("{} = {} {}", slot.text(), module_name.text(), tag.text()) + } + _ => tag.text().to_string(), + }; out.push(ModuleTagDefinition { object: name_lower.clone(), name: tag.text().to_string(), span: tag.text_range().into(), + snippet, is_reference: false, }); } @@ -1153,6 +1186,7 @@ pub fn module_tags_in(_analyzer: &Analyzer, parse: &Parse) -> Vec CompletionI "model": c.label, }) }); + let documentation = c.documentation.map(|value| { + Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value, + }) + }); CompletionItem { label: c.label, kind: Some(match c.kind { @@ -189,6 +195,7 @@ pub fn to_lsp_completion(c: Completion, snippets_supported: bool) -> CompletionI CompletionKind::W3dModel => CompletionItemKind::REFERENCE, }), detail: c.detail, + documentation, data, insert_text, insert_text_format, @@ -354,6 +361,7 @@ mod tests { label: "AVTank".into(), kind: CompletionKind::W3dModel, detail: Some("W3D model".into()), + documentation: None, insert: None, }, false, @@ -365,6 +373,26 @@ mod tests { assert!(item.documentation.is_none()); } + #[test] + fn completion_documentation_is_sent_as_markdown() { + let item = to_lsp_completion( + Completion { + label: "ModuleTag_Physics".into(), + kind: CompletionKind::Reference, + detail: Some("module tag".into()), + documentation: Some( + "```ini\nBehavior = PhysicsBehavior ModuleTag_Physics\n```".into(), + ), + insert: None, + }, + false, + ); + assert!(matches!( + item.documentation, + Some(Documentation::MarkupContent(_)) + )); + } + #[test] fn splice_reproduces_next_from_prev() { let tok = |dl, ds, len, ty| SemanticToken { diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index 9f1c9f1..2fa9a5c 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -482,6 +482,23 @@ def latest_burst_diag(message): assert targets[0]["range"]["start"]["line"] == 1, targets print("OK: RemoveModule tag resolves to its module definition") + # A RemoveModule completion identifies the module that owns each tag. + send({"jsonrpc": "2.0", "id": 69, "method": "textDocument/completion", + "params": {"textDocument": {"uri": module_map_uri}, + "position": {"line": 3, "character": len(" RemoveModule ")}}}) + module_completion = wait_for( + lambda m: m.get("id") == 69 and "result" in m, + "RemoveModule completion result", + ) + module_items = module_completion["result"] + if isinstance(module_items, dict): + module_items = module_items.get("items", []) + target_item = next(item for item in module_items if item["label"] == "ModuleTag_Target") + assert target_item["documentation"]["kind"] == "markdown", target_item + assert target_item["documentation"]["value"].startswith("```ini\n"), target_item + assert "Behavior = DestroyDie ModuleTag_Target" in target_item["documentation"]["value"] + print("OK: RemoveModule completion previews its defining module") + send({"jsonrpc": "2.0", "id": 70, "method": "textDocument/references", "params": {"textDocument": {"uri": module_map_uri}, "position": {"line": 3, "character": 16}, From 869d531bbe11d1bb9edb6b50e76402545b228345 Mon Sep 17 00:00:00 2001 From: ViTeXFTW Date: Sat, 5 Sep 2026 11:29:21 +0200 Subject: [PATCH 2/2] fix: bump workspace cache producer ABI --- crates/server/src/cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/server/src/cache.rs b/crates/server/src/cache.rs index 7ac239b..e5f35fa 100644 --- a/crates/server/src/cache.rs +++ b/crates/server/src/cache.rs @@ -17,7 +17,7 @@ const STORE_FILE: &str = "index-v1.sqlite3"; const STORE_VERSION: i64 = 1; /// Bump when `CachedEntry` serialization or any extractor feeding it changes. /// SQL layout changes instead bump `STORE_VERSION` and the store filename. -const PRODUCER_ABI: &[u8] = b"zerosyntax-physical-input-v2"; +const PRODUCER_ABI: &[u8] = b"zerosyntax-physical-input-v3"; const BUSY_TIMEOUT: Duration = Duration::from_secs(2); const TOUCH_INTERVAL_SECS: i64 = 24 * 60 * 60; const MAX_AGE_SECS: i64 = 30 * 24 * 60 * 60;