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
49 changes: 44 additions & 5 deletions crates/analysis/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub fn complete(
value_index,
(current_token.as_deref(), first_token.as_deref()),
index,
file,
(file, offset),
),
PosContext::ModuleName { slot_accepts } => module_name_completions(analyzer, &slot_accepts),
PosContext::SubBlockArg { argument_type } => {
Expand Down Expand Up @@ -113,8 +113,11 @@ fn classify_position(analyzer: &Analyzer, root: &SyntaxNode, offset: u32) -> Pos
// Are we on a FIELD line? (most common while typing `Key = value`)
if let Some(field_node) = ancestor_of_kind(&node, SyntaxKind::FIELD) {
let scope_node = enclosing_scope(&field_node);
if after_equals(&field_node, offset) {
let field = Field(field_node.clone());
let field = Field(field_node.clone());
let after_key = field
.key()
.is_some_and(|key| u32::from(key.text_range().end()) < offset);
if after_equals(&field_node, offset) || after_key {
let key = field
.key()
.map(|k| k.text().to_string())
Expand Down Expand Up @@ -328,9 +331,10 @@ fn field_value_completions(
value_index: usize,
tokens: (Option<&str>, Option<&str>),
index: Option<&WorkspaceIndex>,
file: Option<&str>,
position: (Option<&str>, u32),
) -> Vec<Completion> {
let (current_token, first_token) = tokens;
let (file, offset) = position;
// RemoveModule / ReplaceModule: suggest module tags from the origin object.
if key.eq_ignore_ascii_case("RemoveModule") || key.eq_ignore_ascii_case("ReplaceModule") {
if let Some(idx) = index {
Expand All @@ -340,7 +344,8 @@ fn field_value_completions(
.unwrap_or_default();
if !obj_name.is_empty() {
let tags: Vec<Completion> = idx
.module_tags_for_object(&obj_name)
.effective_module_tags_for_object(&obj_name, file, Some(offset))
.into_iter()
.map(|tag| Completion {
label: tag.to_string(),
kind: CompletionKind::Reference,
Expand Down Expand Up @@ -950,6 +955,40 @@ mod tests {
);
}

#[test]
fn new_map_object_suggests_default_module_tags() {
let a = Analyzer::embedded();
let defaults = a.parse(
"Object DefaultThingTemplate\n Behavior = DestroyDie ModuleTag_DefaultDestroyDie\n End\nEnd\n",
);
let mut index = WorkspaceIndex::new();
index.set_file_tags(
"data/INI/Default/Object.ini",
crate::index::module_tags_in(&a, &defaults),
);
let src = "Object NewMapObject\n RemoveModule \nEnd\n";
let offset = "Object NewMapObject\n RemoveModule ".len() as u32;
let out = complete(&a, &a.parse(src), offset, Some(&index), Some("map.ini"));
assert!(
out.iter()
.any(|item| item.label == "ModuleTag_DefaultDestroyDie"),
"{out:?}"
);
}

#[test]
fn remove_module_completion_excludes_later_declarations() {
let a = Analyzer::embedded();
let src =
"Object Tank\n RemoveModule \n Behavior = DestroyDie ModuleTag_Later\n End\nEnd\n";
let parse = a.parse(src);
let mut index = WorkspaceIndex::new();
index.set_file_tags("map.ini", crate::index::module_tags_in(&a, &parse));
let offset = "Object Tank\n RemoveModule ".len() as u32;
let out = complete(&a, &parse, offset, Some(&index), Some("map.ini"));
assert!(!out.iter().any(|item| item.label == "ModuleTag_Later"));
}

#[test]
fn enum_value_suggests_members() {
let src = "Weapon AK47\n DeathType = \nEnd\n";
Expand Down
143 changes: 143 additions & 0 deletions crates/analysis/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ pub const KNOWN_CODES: &[&str] = &[
"unknown-field",
"missing-module-tag",
"unknown-module",
"unknown-module-tag",
"missing-condition",
"missing-value",
"bad-bool",
Expand All @@ -102,6 +103,7 @@ pub const KNOWN_CODES: &[&str] = &[
"module-wrong-slot",
"duplicate-module-tag",
"editor-default-module",
"default-modules-not-removed",
];

/// The head word of the in-file suppression pragma comment.
Expand Down Expand Up @@ -783,6 +785,9 @@ impl<'a> Ctx<'a> {
);
}
let is_override_redefinition = self.check_redefinition(node);
if keyword.text().eq_ignore_ascii_case("Object") {
self.check_default_module_removals(node);
}
// Only plain `Object`s: an ObjectReskin inherits its parent's
// modules and sets, so neither side of the pairing is visible —
// and a map.ini override redefinition inherits the base object's
Expand All @@ -794,6 +799,45 @@ impl<'a> Ctx<'a> {
self.walk(node, &schema);
}

fn check_default_module_removals(&mut self, node: &SyntaxNode) {
let (Some(index), Some(file), Some(name)) =
(self.index, self.file, Block(node.clone()).name())
else {
return;
};
if name.text().eq_ignore_ascii_case("DefaultThingTemplate")
|| !index.is_new_override_object(name.text(), file)
{
return;
}
let removed = Block(node.clone())
.fields()
.filter(|field| {
field
.key()
.is_some_and(|key| key.text().eq_ignore_ascii_case("RemoveModule"))
})
.filter_map(|field| field.value_tokens().first().cloned())
.map(|tag| unquote(tag.text()).to_ascii_lowercase())
.collect::<HashSet<_>>();
let mut seen = HashSet::new();
let remaining = index
.module_tags_for_object("DefaultThingTemplate")
.filter(|tag| !removed.contains(&tag.to_ascii_lowercase()))
.filter(|tag| seen.insert(tag.to_ascii_lowercase()))
.collect::<Vec<_>>();
if !remaining.is_empty() {
self.hint(
&name,
"default-modules-not-removed",
format!(
"new map object inherits default modules {}; remove them with `RemoveModule <tag>`",
remaining.join(", ")
),
);
}
}

/// Cross-file redefinition handling for named definition blocks, driven
/// purely by the index's name table (so it stays sound under the
/// per-block cache: the generation bumps whenever any file's definition
Expand Down Expand Up @@ -990,6 +1034,9 @@ impl<'a> Ctx<'a> {

if let Some(schema_field) = scope.field(name) {
self.validate_value(&field, &schema_field.value_type);
if name.eq_ignore_ascii_case("RemoveModule") {
self.validate_remove_module(&field, scope_node);
}
self.validate_model_asset(&field, schema_field, scope_node);
self.validate_raw_asset(&field, &schema_field.value_type);
} else if scope.has_field_schema()
Expand All @@ -1003,6 +1050,38 @@ impl<'a> Ctx<'a> {
}
}

fn validate_remove_module(&mut self, field: &Field, scope_node: &SyntaxNode) {
if !self.file.is_some_and(is_override_layer) {
return;
}
let (Some(index), Some(tag), Some(object)) = (
self.index,
field.value_tokens().first().cloned(),
Block(scope_node.clone()).name(),
) else {
return;
};
let tag_name = unquote(tag.text());
if !index
.effective_module_tags_for_object(
object.text(),
self.file,
Some(tag.text_range().start().into()),
)
.iter()
.any(|known| known.eq_ignore_ascii_case(tag_name))
{
self.error(
&tag,
"unknown-module-tag",
format!(
"`{tag_name}` is not a known module tag on `{}`",
object.text()
),
);
}
}

fn validate_model_asset(
&mut self,
field: &Field,
Expand Down Expand Up @@ -2335,6 +2414,70 @@ End
);
}

#[test]
fn solo_remove_module_includes_default_object_tags() {
let a = Analyzer::embedded();
let mut index = WorkspaceIndex::new();
let base = "Object DefaultThingTemplate\n Behavior = DestroyDie ModuleTag_DefaultDestroyDie\n End\nEnd\n";
let base_parse = a.parse(base);
index.set_file_tags(
"data/INI/Default/Object.ini",
crate::index::module_tags_in(&a, &base_parse),
);
assert_eq!(
index.effective_module_tag_locations(
"NewMapObject",
"ModuleTag_DefaultDestroyDie",
Some("maps/solo.ini"),
None,
)[0]
.file,
"data/INI/Default/Object.ini"
);

let src = "Object NewMapObject\n RemoveModule ModuleTag_DefaultDestroyDie\n RemoveModule ModuleTag_Missing\nEnd\n";
let parse = a.parse(src);
let diags = diagnose(&a, &parse, Some(&index), Some("maps/solo.ini"));
let unknown: Vec<_> = diags
.iter()
.filter(|d| d.code == "unknown-module-tag")
.collect();
assert_eq!(unknown.len(), 1, "{diags:?}");
assert_eq!(
&src[unknown[0].span.start as usize..unknown[0].span.end as usize],
"ModuleTag_Missing"
);
assert!(
!diags
.iter()
.any(|diag| diag.code == "default-modules-not-removed"),
"{diags:?}"
);

let unremoved = a.parse("Object AnotherMapObject\nEnd\n");
let diags = diagnose(&a, &unremoved, Some(&index), Some("maps/solo.ini"));
assert!(
diags
.iter()
.any(|diag| diag.code == "default-modules-not-removed"),
"{diags:?}"
);
}

#[test]
fn remove_module_rejects_tags_declared_later_in_the_same_map() {
let a = Analyzer::embedded();
let src = "Object Tank\n RemoveModule ModuleTag_Later\n Behavior = DestroyDie ModuleTag_Later\n End\nEnd\n";
let parse = a.parse(src);
let mut index = WorkspaceIndex::new();
index.set_file_tags("maps/map.ini", crate::index::module_tags_in(&a, &parse));
let diags = diagnose(&a, &parse, Some(&index), Some("maps/map.ini"));
assert!(
diags.iter().any(|diag| diag.code == "unknown-module-tag"),
"{diags:?}"
);
}

#[test]
fn map_forward_reference_allows_base_game_definition() {
let a = Analyzer::embedded();
Expand Down
Loading