Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion crates/analysis/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct Completion {
pub label: String,
pub kind: CompletionKind,
pub detail: Option<String>,
/// Optional Markdown shown by an LSP client alongside the selected item.
pub documentation: Option<String>,
/// 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;
Expand Down Expand Up @@ -289,6 +291,7 @@ fn object_name_completions(index: Option<&WorkspaceIndex>) -> Vec<Completion> {
label: name.to_string(),
kind: CompletionKind::Reference,
detail: Some("Object (override target)".into()),
documentation: None,
insert: None,
})
.collect()
Expand All @@ -303,6 +306,7 @@ fn field_key_completions(analyzer: &Analyzer, scope_node: &SyntaxNode) -> Vec<Co
label: f.name.clone(),
kind: CompletionKind::Field,
detail: Some(type_label(&f.value_type)),
documentation: None,
insert: value_snippet(&f.value_type).map(|value| format!("{} = {value}", f.name)),
})
.collect();
Expand All @@ -313,6 +317,7 @@ fn field_key_completions(analyzer: &Analyzer, scope_node: &SyntaxNode) -> Vec<Co
label: slot.keyword.clone(),
kind: CompletionKind::Field,
detail: Some("module slot".into()),
documentation: None,
insert,
});
}
Expand All @@ -336,6 +341,7 @@ fn field_key_completions(analyzer: &Analyzer, scope_node: &SyntaxNode) -> Vec<Co
label: sub.keyword.clone(),
kind: CompletionKind::Block,
detail: Some("sub-block".into()),
documentation: None,
insert,
});
}
Expand Down Expand Up @@ -388,9 +394,11 @@ fn field_value_completions(
.effective_module_tags_for_object(&obj_name, file, Some(offset))
.into_iter()
.map(|tag| Completion {
label: tag.to_string(),
label: tag.name.to_string(),
kind: CompletionKind::Reference,
detail: Some("module tag".into()),
documentation: (!tag.snippet.is_empty())
.then(|| format!("```ini\n{}\n```", tag.snippet)),
insert: None,
})
.collect();
Expand Down Expand Up @@ -441,6 +449,7 @@ fn field_value_completions(
label: k.to_string(),
kind: CompletionKind::Value,
detail: Some("string key".into()),
documentation: None,
insert: None,
}));
}
Expand Down Expand Up @@ -477,6 +486,7 @@ fn model_asset_completions(
label: name.to_string(),
kind: CompletionKind::W3dModel,
detail: Some("W3D model".into()),
documentation: None,
insert: None,
})
.collect(),
Expand Down Expand Up @@ -525,6 +535,7 @@ fn model_asset_completions(
}),
label: member,
kind: CompletionKind::Reference,
documentation: None,
})
.collect();
Some(out)
Expand Down Expand Up @@ -691,6 +702,7 @@ fn completions_for_type(
label: "<full sequence>".into(),
kind: CompletionKind::Value,
detail: Some(format!("{} tokens", tokens.len())),
documentation: None,
insert: Some(snippet),
},
);
Expand All @@ -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"]
Expand All @@ -723,6 +738,7 @@ fn completions_for_type(
label: v.to_string(),
kind: CompletionKind::Value,
detail: None,
documentation: None,
insert: None,
})
.collect(),
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -835,6 +854,7 @@ fn asset_completions(
label,
kind: CompletionKind::Reference,
detail: Some(detail.to_string()),
documentation: None,
insert: None,
})
.collect()
Expand All @@ -858,6 +878,7 @@ fn top_level_completions(analyzer: &Analyzer) -> Vec<Completion> {
label: b.name.clone(),
kind: CompletionKind::Block,
detail: Some("block".into()),
documentation: None,
insert,
}
})
Expand Down Expand Up @@ -885,6 +906,7 @@ fn module_name_completions(
label: m.name.clone(),
kind: CompletionKind::Module,
detail: Some("module".into()),
documentation: None,
insert,
}
})
Expand Down Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion crates/analysis/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 40 additions & 5 deletions crates/analysis/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
ViTeXFTW marked this conversation as resolved.
#[serde(default)]
pub is_reference: bool,
}
Expand All @@ -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.
Expand Down Expand Up @@ -574,6 +586,7 @@ impl WorkspaceIndex {
.push(ModuleTagEntry {
name: tag.name,
location,
snippet: tag.snippet,
});
}
}
Expand Down Expand Up @@ -648,7 +661,7 @@ impl WorkspaceIndex {
name: &str,
file: Option<&str>,
before: Option<u32>,
) -> Vec<&'a str> {
) -> Vec<EffectiveModuleTag<'a>> {
let mut out = self
.object_tags
.get(&name.to_ascii_lowercase())
Expand All @@ -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::<Vec<_>>();
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
}
Expand Down Expand Up @@ -1149,11 +1174,19 @@ pub fn module_tags_in(_analyzer: &Analyzer, parse: &Parse) -> Vec<ModuleTagDefin
let Some(name) = block.name() else { continue };
let name_lower = name.text().to_ascii_lowercase();
for child in node.children().filter(|n| n.kind() == SyntaxKind::MODULE) {
if let Some(tag) = Module(child).tag() {
let module = Module(child);
if let Some(tag) = module.tag() {
let snippet = match (module.slot(), module.module_name()) {
(Some(slot), Some(module_name)) => {
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,
});
}
Expand All @@ -1170,6 +1203,7 @@ pub fn module_tags_in(_analyzer: &Analyzer, parse: &Parse) -> Vec<ModuleTagDefin
object: name_lower.clone(),
name: tag.text().trim_matches('"').to_string(),
span: tag.text_range().into(),
snippet: String::new(),
is_reference: true,
});
}
Expand Down Expand Up @@ -1242,6 +1276,7 @@ mod tests {
object: "tank".into(),
name: "ModuleTag_Physics".into(),
span: Span::new(0, 17),
snippet: "Behavior = PhysicsBehavior ModuleTag_Physics".into(),
is_reference: false,
}];
let g0 = idx.generation();
Expand Down
2 changes: 1 addition & 1 deletion crates/server/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions crates/server/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ pub fn to_lsp_completion(c: Completion, snippets_supported: bool) -> 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 {
Expand All @@ -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,
Expand Down Expand Up @@ -354,6 +361,7 @@ mod tests {
label: "AVTank".into(),
kind: CompletionKind::W3dModel,
detail: Some("W3D model".into()),
documentation: None,
insert: None,
},
false,
Expand All @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions crates/server/tests/e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down