diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index e3dc27c..6dfa969 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; @@ -289,6 +291,7 @@ fn object_name_completions(index: Option<&WorkspaceIndex>) -> Vec { label: name.to_string(), kind: CompletionKind::Reference, detail: Some("Object (override target)".into()), + documentation: None, insert: None, }) .collect() @@ -303,6 +306,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), }, ); @@ -703,18 +715,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"] @@ -723,6 +738,7 @@ fn completions_for_type( label: v.to_string(), kind: CompletionKind::Value, detail: None, + documentation: None, insert: None, }) .collect(), @@ -735,6 +751,7 @@ fn completions_for_type( label: m.name.clone(), kind: CompletionKind::EnumMember, detail: Some(value_set.clone()), + documentation: None, insert: None, }) .collect() @@ -748,6 +765,7 @@ fn completions_for_type( label: n.to_string(), kind: CompletionKind::Reference, detail: Some(format!("{ref_kind:?}")), + documentation: None, insert: None, }) .collect() @@ -759,6 +777,7 @@ fn completions_for_type( label: n.to_string(), kind: CompletionKind::Reference, detail: Some(format!("{ref_kind:?} (engine builtin)")), + documentation: None, insert: None, })); out @@ -835,6 +854,7 @@ fn asset_completions( label, kind: CompletionKind::Reference, detail: Some(detail.to_string()), + documentation: None, insert: None, }) .collect() @@ -858,6 +878,7 @@ fn top_level_completions(analyzer: &Analyzer) -> Vec { label: b.name.clone(), kind: CompletionKind::Block, detail: Some("block".into()), + documentation: None, insert, } }) @@ -885,6 +906,7 @@ fn module_name_completions( label: m.name.clone(), kind: CompletionKind::Module, detail: Some("module".into()), + documentation: None, insert, } }) @@ -1179,6 +1201,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 db08a92..1566f6d 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 d97d5f3..298fd4d 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 } @@ -1149,11 +1174,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, }); } @@ -1170,6 +1203,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},