diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index 120c78d..fcb9136 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -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 } => { @@ -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()) @@ -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 { 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 { @@ -340,7 +344,8 @@ fn field_value_completions( .unwrap_or_default(); if !obj_name.is_empty() { let tags: Vec = 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, @@ -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"; diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index 35304ca..03178bc 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -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", @@ -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. @@ -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 @@ -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::>(); + 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::>(); + if !remaining.is_empty() { + self.hint( + &name, + "default-modules-not-removed", + format!( + "new map object inherits default modules {}; remove them with `RemoveModule `", + 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 @@ -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() @@ -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, @@ -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(); diff --git a/crates/analysis/src/index.rs b/crates/analysis/src/index.rs index c168b20..c97c5f8 100644 --- a/crates/analysis/src/index.rs +++ b/crates/analysis/src/index.rs @@ -57,6 +57,15 @@ pub struct ReferenceSite { pub span: Span, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleTagDefinition { + pub object: String, + pub name: String, + pub span: Span, + #[serde(default)] + pub is_reference: bool, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ModelMemberStrictness { Off, @@ -72,6 +81,11 @@ struct NameEntry { locations: Vec, } +struct ModuleTagEntry { + name: String, + location: Location, +} + /// Workspace-wide symbol table, grouped by reference kind then name. /// /// Name lookup is **case-insensitive**, mirroring the engine: shipped game @@ -95,9 +109,11 @@ pub struct WorkspaceIndex { generation: u64, /// Module tags per object (case-insensitive object name key). /// Populated from all indexed files. Powers RemoveModule completions. - object_tags: HashMap>, - /// Reverse map: file → (object_lower, tag) for removal on re-index. - file_tags: HashMap>, + object_tags: HashMap>, + /// RemoveModule value sites, keyed by (object_lower, tag_lower). + object_tag_sites: HashMap<(String, String), Vec>, + /// Reverse map: file → (object_lower, tag, is_reference) for re-indexing. + file_tags: HashMap>, /// String table keys from companion `.str` files, keyed by the INI file URI. /// Powers DisplayName completions when a map.str is present. ini_str_keys: HashMap>, @@ -203,6 +219,7 @@ impl WorkspaceIndex { .file_object_parents .get(file) .is_some_and(|v| !v.is_empty()) + || self.file_tags.get(file).is_some_and(|v| !v.is_empty()) { self.generation += 1; } @@ -212,6 +229,7 @@ impl WorkspaceIndex { self.set_file_assets(file, Vec::new()); self.remove_object_model_entries(file); self.remove_object_parent_entries(file); + self.remove_tag_entries(file); } fn remove_site_entries(&mut self, file: &str) { @@ -451,37 +469,175 @@ impl WorkspaceIndex { /// Replace module-tag entries contributed by `file`. /// Called alongside `set_file` so RemoveModule completions stay current. - pub fn set_file_tags(&mut self, file: &str, tags: Vec<(String, String)>) { - if let Some(old) = self.file_tags.remove(file) { - for (obj_lower, tag) in old { - if let Some(list) = self.object_tags.get_mut(&obj_lower) { - list.retain(|t| !t.eq_ignore_ascii_case(&tag)); - if list.is_empty() { - self.object_tags.remove(&obj_lower); - } - } - } + pub fn set_file_tags(&mut self, file: &str, tags: Vec) { + let entries = tags + .iter() + .map(|tag| { + ( + tag.object.to_ascii_lowercase(), + tag.name.clone(), + tag.is_reference, + ) + }) + .collect::>(); + let normalized = normalize_object_tags( + &entries + .iter() + .filter(|(_, _, is_reference)| !is_reference) + .map(|(object, name, _)| (object.clone(), name.clone())) + .collect::>(), + ); + if self + .file_tags + .get(file) + .map(|old| { + normalize_object_tags( + &old.iter() + .filter(|(_, _, is_reference)| !is_reference) + .map(|(object, name, _)| (object.clone(), name.clone())) + .collect::>(), + ) + }) + .unwrap_or_default() + != normalized + { + self.generation += 1; } - let mut entries = Vec::with_capacity(tags.len()); - for (obj_lower, tag) in &tags { - self.object_tags - .entry(obj_lower.clone()) - .or_default() - .push(tag.clone()); - entries.push((obj_lower.clone(), tag.clone())); + self.remove_tag_entries(file); + for tag in tags { + let object = tag.object.to_ascii_lowercase(); + let location = Location { + file: file.to_string(), + span: tag.span, + }; + if tag.is_reference { + self.object_tag_sites + .entry((object, tag.name.to_ascii_lowercase())) + .or_default() + .push(location); + } else { + self.object_tags + .entry(object) + .or_default() + .push(ModuleTagEntry { + name: tag.name, + location, + }); + } } if !entries.is_empty() { self.file_tags.insert(file.to_string(), entries); } } + fn remove_tag_entries(&mut self, file: &str) { + if let Some(old) = self.file_tags.remove(file) { + for (object, name, _) in old { + if let Some(tags) = self.object_tags.get_mut(&object) { + tags.retain(|tag| tag.location.file != file); + if tags.is_empty() { + self.object_tags.remove(&object); + } + } + let key = (object, name.to_ascii_lowercase()); + if let Some(sites) = self.object_tag_sites.get_mut(&key) { + sites.retain(|site| site.file != file); + if sites.is_empty() { + self.object_tag_sites.remove(&key); + } + } + } + } + } + /// Module tags defined on `object_name` (case-insensitive) across all /// indexed files. Used to populate RemoveModule value completions. pub fn module_tags_for_object<'a>(&'a self, name: &str) -> impl Iterator { self.object_tags .get(&name.to_ascii_lowercase()) .into_iter() - .flat_map(|tags| tags.iter().map(|t| t.as_str())) + .flat_map(|tags| tags.iter().map(|tag| tag.name.as_str())) + } + + pub fn module_tag_locations<'a>(&'a self, object: &str, tag: &str) -> Vec<&'a Location> { + self.object_tags + .get(&object.to_ascii_lowercase()) + .into_iter() + .flatten() + .filter(|entry| entry.name.eq_ignore_ascii_case(tag)) + .map(|entry| &entry.location) + .collect() + } + + pub fn module_tag_reference_locations<'a>( + &'a self, + object: &str, + tag: &str, + ) -> Vec<&'a Location> { + self.object_tag_sites + .get(&(object.to_ascii_lowercase(), tag.to_ascii_lowercase())) + .into_iter() + .flatten() + .collect() + } + + pub fn is_new_override_object(&self, name: &str, file: &str) -> bool { + is_override_layer(file) + && !self + .locations(RefKind::Object, name) + .iter() + .any(|location| !is_override_layer(&location.file)) + } + + /// Module tags visible to RemoveModule. New map/solo objects start as a + /// copy of DefaultThingTemplate, while existing objects use their own tags. + pub fn effective_module_tags_for_object<'a>( + &'a self, + name: &str, + file: Option<&str>, + before: Option, + ) -> Vec<&'a str> { + let mut out = self + .object_tags + .get(&name.to_ascii_lowercase()) + .into_iter() + .flatten() + .filter(|tag| { + !file.zip(before).is_some_and(|(file, before)| { + tag.location.file == file && tag.location.span.start >= before + }) + }) + .map(|tag| tag.name.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")); + let mut seen = std::collections::HashSet::new(); + out.retain(|tag| seen.insert(tag.to_ascii_lowercase())); + } + out + } + + pub fn effective_module_tag_locations<'a>( + &'a self, + object: &str, + tag: &str, + file: Option<&str>, + before: Option, + ) -> Vec<&'a Location> { + let mut out = self + .module_tag_locations(object, tag) + .into_iter() + .filter(|location| { + !file.zip(before).is_some_and(|(file, before)| { + location.file == file && location.span.start >= before + }) + }) + .collect::>(); + if out.is_empty() && file.is_some_and(|file| self.is_new_override_object(object, file)) { + out = self.module_tag_locations("DefaultThingTemplate", tag); + } + out } /// Store string table keys parsed from the `.str` file co-located with `ini_file`. @@ -634,6 +790,21 @@ fn normalize_object_models(objects: &[(String, Vec)]) -> Vec<(String, Ve out } +fn normalize_object_tags(tags: &[(String, String)]) -> Vec<(String, String)> { + let mut out = tags + .iter() + .map(|(object, tag)| (object.to_ascii_lowercase(), tag.to_ascii_lowercase())) + .collect::>(); + out.sort(); + out +} + +fn is_override_layer(file: &str) -> bool { + file.rsplit(['/', '\\']).next().is_some_and(|name| { + name.eq_ignore_ascii_case("map.ini") || name.eq_ignore_ascii_case("solo.ini") + }) +} + /// Collect the W3D models declared below every Object definition. pub fn object_models_in(analyzer: &Analyzer, parse: &Parse) -> Vec<(String, Vec)> { parse @@ -865,9 +1036,9 @@ pub fn definitions_in(analyzer: &Analyzer, parse: &Parse, _file: &str) -> Vec Vec<(String, String)> { +/// Collect module-tag declarations and RemoveModule reference sites from all +/// Object blocks in a parsed file. +pub fn module_tags_in(_analyzer: &Analyzer, parse: &Parse) -> Vec { let mut out = Vec::new(); for node in parse.syntax().children() { if node.kind() != SyntaxKind::BLOCK { @@ -882,7 +1053,28 @@ pub fn module_tags_in(_analyzer: &Analyzer, parse: &Parse) -> Vec<(String, Strin 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() { - out.push((name_lower.clone(), tag.text().to_string())); + out.push(ModuleTagDefinition { + object: name_lower.clone(), + name: tag.text().to_string(), + span: tag.text_range().into(), + is_reference: false, + }); + } + } + for field in block.fields() { + if !field + .key() + .is_some_and(|key| key.text().eq_ignore_ascii_case("RemoveModule")) + { + continue; + } + if let Some(tag) = field.value_tokens().first() { + out.push(ModuleTagDefinition { + object: name_lower.clone(), + name: tag.text().trim_matches('"').to_string(), + span: tag.text_range().into(), + is_reference: true, + }); } } } @@ -946,6 +1138,33 @@ mod tests { assert_eq!(idx.generation(), g2); } + #[test] + fn module_tags_invalidate_diagnostics_and_remove_per_file() { + let mut idx = WorkspaceIndex::new(); + let tag = vec![ModuleTagDefinition { + object: "tank".into(), + name: "ModuleTag_Physics".into(), + span: Span::new(0, 17), + is_reference: false, + }]; + let g0 = idx.generation(); + idx.set_file_tags("base.ini", tag.clone()); + assert_ne!(idx.generation(), g0); + + idx.set_file_tags("patch.ini", tag); + idx.remove_file("base.ini"); + assert_eq!( + idx.module_tags_for_object("Tank").collect::>(), + vec!["ModuleTag_Physics"] + ); + assert_eq!( + idx.module_tag_locations("Tank", "ModuleTag_Physics")[0].file, + "patch.ini" + ); + idx.remove_file("patch.ini"); + assert_eq!(idx.module_tags_for_object("Tank").count(), 0); + } + #[test] fn collects_and_stores_reference_sites() { let a = Analyzer::embedded(); diff --git a/crates/analysis/src/nav.rs b/crates/analysis/src/nav.rs index cfe95c2..0791771 100644 --- a/crates/analysis/src/nav.rs +++ b/crates/analysis/src/nav.rs @@ -2,7 +2,7 @@ //! go-to-definition and hover. use zerosyntax_schema::{RefKind, ValueType}; -use zerosyntax_syntax::ast::{Block, Field}; +use zerosyntax_syntax::ast::{Block, Field, Module}; use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode, SyntaxToken}; use crate::model::scope_schema; @@ -15,6 +15,12 @@ pub struct ReferenceAt { pub span: Span, } +pub struct ModuleTagReferenceAt { + pub object: String, + pub name: String, + pub span: Span, +} + /// What the token under the cursor means, for hover. pub enum HoverInfo { Block { @@ -101,6 +107,67 @@ pub fn reference_at(analyzer: &Analyzer, parse: &Parse, offset: u32) -> Option` value to its owning object and tag name. +pub fn module_tag_reference_at(parse: &Parse, offset: u32) -> Option { + let root = parse.syntax(); + let tok = token_at(&root, offset)?; + let field_node = tok + .parent() + .filter(|parent| parent.kind() == SyntaxKind::FIELD)?; + let field = Field(field_node.clone()); + if !field + .key() + .is_some_and(|key| key.text().eq_ignore_ascii_case("RemoveModule")) + || field.value_tokens().first() != Some(&tok) + { + return None; + } + let object = field_node + .ancestors() + .skip(1) + .find(|node| node.kind() == SyntaxKind::BLOCK) + .map(Block)?; + if !object + .keyword() + .is_some_and(|keyword| keyword.text().eq_ignore_ascii_case("Object")) + { + return None; + } + Some(ModuleTagReferenceAt { + object: object.name()?.text().to_string(), + name: tok.text().trim_matches('"').to_string(), + span: tok.text_range().into(), + }) +} + +/// Resolve a module declaration tag to its owning object and tag name. +pub fn module_tag_definition_at(parse: &Parse, offset: u32) -> Option { + let root = parse.syntax(); + let tok = token_at(&root, offset)?; + let module_node = tok + .parent() + .filter(|parent| parent.kind() == SyntaxKind::MODULE)?; + if Module(module_node.clone()).tag().as_ref() != Some(&tok) { + return None; + } + let object = module_node + .ancestors() + .skip(1) + .find(|node| node.kind() == SyntaxKind::BLOCK) + .map(Block)?; + if !object + .keyword() + .is_some_and(|keyword| keyword.text().eq_ignore_ascii_case("Object")) + { + return None; + } + Some(ModuleTagReferenceAt { + object: object.name()?.text().to_string(), + name: tok.text().trim_matches('"').to_string(), + span: tok.text_range().into(), + }) +} + /// If `offset` sits on a *definition's* name token (the second header word of /// a block whose keyword `defines` a reference kind), resolve it. Together /// with [`reference_at`] this powers find-references and rename from either @@ -192,4 +259,24 @@ mod tests { "MissingParticle" ); } + + #[test] + fn remove_module_value_is_a_scoped_module_tag_reference() { + let a = Analyzer::embedded(); + let src = "Object Tank\n RemoveModule ModuleTag_01\nEnd\n"; + let offset = src.find("ModuleTag_01").unwrap() as u32; + let reference = module_tag_reference_at(&a.parse(src), offset).unwrap(); + assert_eq!(reference.object, "Tank"); + assert_eq!(reference.name, "ModuleTag_01"); + } + + #[test] + fn module_declaration_tag_is_scoped_to_its_object() { + let a = Analyzer::embedded(); + let src = "Object Tank\n Behavior = DestroyDie ModuleTag_01\n End\nEnd\n"; + let offset = src.find("ModuleTag_01").unwrap() as u32; + let definition = module_tag_definition_at(&a.parse(src), offset).unwrap(); + assert_eq!(definition.object, "Tank"); + assert_eq!(definition.name, "ModuleTag_01"); + } } diff --git a/crates/analysis/src/semantic.rs b/crates/analysis/src/semantic.rs index 4a8d91d..b13b79f 100644 --- a/crates/analysis/src/semantic.rs +++ b/crates/analysis/src/semantic.rs @@ -142,6 +142,11 @@ impl<'a> Sem<'a> { }, ); } + if is_real_module { + if let Some(tag) = module.tag() { + self.set(&tag, SemKind::Reference); + } + } } fn walk(&mut self, node: &SyntaxNode, scope: &ScopeSchema) { @@ -171,8 +176,18 @@ impl<'a> Sem<'a> { } fn field(&mut self, field: &Field, scope: &ScopeSchema) { + let is_remove_module = field + .key() + .is_some_and(|key| key.text().eq_ignore_ascii_case("RemoveModule")); if let Some(key) = field.key() { - self.set(&key, SemKind::Field); + self.set( + &key, + if is_remove_module { + SemKind::Keyword + } else { + SemKind::Field + }, + ); } let ty = field .key() @@ -187,6 +202,10 @@ impl<'a> Sem<'a> { .map(|token| token.text().trim_matches('"')) .collect::>(); for (i, tok) in value_tokens.iter().enumerate() { + if is_remove_module { + self.set(tok, SemKind::Reference); + continue; + } if matches!(active_ty, Some(ValueType::RandomVariable { .. })) { self.set( tok, @@ -314,6 +333,21 @@ mod tests { } } + #[test] + fn remove_module_is_keyword_and_tag_is_reference() { + let src = "Object Tank\n Behavior = DestroyDie ModuleTag_01\n End\n RemoveModule ModuleTag_01\nEnd\n"; + let t = toks(src); + assert!(t + .iter() + .any(|(kind, text)| *kind == SemKind::Keyword && text == "RemoveModule")); + assert_eq!( + t.iter() + .filter(|(kind, text)| *kind == SemKind::Reference && text == "ModuleTag_01") + .count(), + 2 + ); + } + #[test] fn range_tokens_cover_exactly_the_intersecting_blocks() { let a = Analyzer::embedded(); diff --git a/crates/analysis/tests/spec.rs b/crates/analysis/tests/spec.rs index 9b9076e..8714851 100644 --- a/crates/analysis/tests/spec.rs +++ b/crates/analysis/tests/spec.rs @@ -38,7 +38,9 @@ use std::path::{Path, PathBuf}; use zerosyntax_analysis::actions; use zerosyntax_analysis::completion::complete; use zerosyntax_analysis::diagnostics::{diagnose, Severity}; -use zerosyntax_analysis::index::{definitions_in, AssetKind, FileAsset, WorkspaceIndex}; +use zerosyntax_analysis::index::{ + definitions_in, module_tags_in, AssetKind, FileAsset, WorkspaceIndex, +}; use zerosyntax_analysis::{Analyzer, Span}; use serde::Deserialize; @@ -397,6 +399,7 @@ fn specs_hold() { // the definitions it declares (and only those). let mut index = WorkspaceIndex::new(); index.set_file(&name, definitions_in(&analyzer, &parse, &name)); + index.set_file_tags(&name, module_tags_in(&analyzer, &parse)); index.set_file_assets( "spec-assets", spec.audio_assets diff --git a/crates/analysis/tests/spec/Map.ini b/crates/analysis/tests/spec/Map.ini index e1a369a..29a1b18 100644 --- a/crates/analysis/tests/spec/Map.ini +++ b/crates/analysis/tests/spec/Map.ini @@ -7,6 +7,15 @@ ; lives in the shipped game data; in this single-file spec workspace it is ; the first block. +; Single-file stand-in for Data/INI/Default/Object.ini. +Object DefaultThingTemplate + Behavior = DestroyDie ModuleTag_DefaultDestroyDie + End +End + +Object NewMapObject +End + Object CINE_RangerPatch Behavior = WeaponSetUpgrade ModuleTag_Flash TriggeredBy = Upgrade_Veterancy_ELITE @@ -14,6 +23,7 @@ Object CINE_RangerPatch End Object CINE_RangerPatch + RemoveModule $1 WeaponSet Conditions = None Weapon = PRIMARY DefaultRangerCombatRifle diff --git a/crates/analysis/tests/spec/Map.spec.toml b/crates/analysis/tests/spec/Map.spec.toml index b0d9ee2..738eb71 100644 --- a/crates/analysis/tests/spec/Map.spec.toml +++ b/crates/analysis/tests/spec/Map.spec.toml @@ -102,3 +102,12 @@ code = "map-forward-reference" on = "LateStoredUpgrade" nth = 1 absent = true + +[[complete]] +at = "$1" +includes = ["ModuleTag_Flash"] + +[[diag]] +severity = "hint" +code = "default-modules-not-removed" +on = "NewMapObject" diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index d7dcc31..caf22cd 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -21,7 +21,10 @@ use zerosyntax_analysis::index::{ definitions_in, module_tags_in, object_models_in, object_parents_in, references_in, ModelMemberStrictness, WorkspaceIndex, }; -use zerosyntax_analysis::nav::{definition_at, hover_at, reference_at, HoverInfo}; +use zerosyntax_analysis::nav::{ + definition_at, hover_at, module_tag_definition_at, module_tag_reference_at, reference_at, + HoverInfo, ModuleTagReferenceAt, ReferenceAt, +}; use zerosyntax_analysis::{actions, completion, diagnostics, format, outline, semantic, Analyzer}; use zerosyntax_syntax::{Edit, Parse}; @@ -52,6 +55,23 @@ struct DocumentState { last_semantic: Option<(u64, Vec)>, } +enum SymbolAt { + Reference(ReferenceAt), + ModuleTag { + symbol: ModuleTagReferenceAt, + before: Option, + }, +} + +impl SymbolAt { + fn span(&self) -> zerosyntax_analysis::Span { + match self { + Self::Reference(symbol) => symbol.span, + Self::ModuleTag { symbol, .. } => symbol.span, + } + } +} + const DEFAULT_ANALYSIS_DEBOUNCE_MS: u64 = 250; const MAX_ANALYSIS_DEBOUNCE_MS: u64 = 5_000; const FORMATTING_REGISTRATION_ID: &str = "zerosyntax-formatting"; @@ -865,11 +885,25 @@ impl Backend { /// The (kind, name, span) under the cursor — a reference-typed value token /// or a definition's name token. The shared entry point for /// find-references and rename, which work from either end of an edge. - fn symbol_at(&self, uri: &Url, pos: Position) -> Option { + fn symbol_at(&self, uri: &Url, pos: Position) -> Option { let (rope, parse) = self.doc(uri)?; let offset = convert::position_to_offset(&rope, pos, self.enc()); let analyzer = self.analyzer(); - reference_at(&analyzer, &parse, offset).or_else(|| definition_at(&analyzer, &parse, offset)) + reference_at(&analyzer, &parse, offset) + .or_else(|| definition_at(&analyzer, &parse, offset)) + .map(SymbolAt::Reference) + .or_else(|| { + module_tag_reference_at(&parse, offset).map(|symbol| SymbolAt::ModuleTag { + before: Some(symbol.span.start), + symbol, + }) + }) + .or_else(|| { + module_tag_definition_at(&parse, offset).map(|symbol| SymbolAt::ModuleTag { + symbol, + before: None, + }) + }) } /// Convert `(file uri, span)` pairs to LSP locations, reading each file's @@ -1478,18 +1512,28 @@ impl LanguageServer for Backend { }; let enc = self.enc(); let offset = convert::position_to_offset(&rope, pos, enc); - let Some(reference) = reference_at(&self.analyzer(), &parse, offset) else { - return Ok(None); - }; - let locations: Vec<(String, zerosyntax_analysis::Span)> = { let Ok(idx) = self.index.read() else { return Ok(None); }; - idx.locations(reference.kind, &reference.name) - .iter() - .map(|l| (l.file.clone(), l.span)) + if let Some(reference) = reference_at(&self.analyzer(), &parse, offset) { + idx.locations(reference.kind, &reference.name) + .iter() + .map(|location| (location.file.clone(), location.span)) + .collect() + } else if let Some(reference) = module_tag_reference_at(&parse, offset) { + idx.effective_module_tag_locations( + &reference.object, + &reference.name, + Some(uri.as_str()), + Some(reference.span.start), + ) + .into_iter() + .map(|location| (location.file.clone(), location.span)) .collect() + } else { + return Ok(None); + } }; let mut out = Vec::new(); @@ -1598,19 +1642,48 @@ impl LanguageServer for Backend { let Ok(idx) = self.index.read() else { return Ok(None); }; - let mut v: Vec<_> = idx - .reference_sites(sym.kind, &sym.name) - .iter() - .map(|l| (l.file.clone(), l.span)) - .collect(); - if params.context.include_declaration { - v.extend( - idx.locations(sym.kind, &sym.name) + match &sym { + SymbolAt::Reference(sym) => { + let mut locations = idx + .reference_sites(sym.kind, &sym.name) .iter() - .map(|l| (l.file.clone(), l.span)), - ); + .map(|l| (l.file.clone(), l.span)) + .collect::>(); + if params.context.include_declaration { + locations.extend( + idx.locations(sym.kind, &sym.name) + .iter() + .map(|l| (l.file.clone(), l.span)), + ); + } + locations + } + SymbolAt::ModuleTag { symbol, before } => { + let mut locations = idx + .module_tag_reference_locations(&symbol.object, &symbol.name) + .into_iter() + .map(|location| (location.file.clone(), location.span)) + .collect::>(); + if params.context.include_declaration { + let definitions = if before.is_some() { + idx.effective_module_tag_locations( + &symbol.object, + &symbol.name, + Some(uri.as_str()), + *before, + ) + } else { + idx.module_tag_locations(&symbol.object, &symbol.name) + }; + locations.extend( + definitions + .into_iter() + .map(|location| (location.file.clone(), location.span)), + ); + } + locations + } } - v }; raw.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.start.cmp(&b.1.start))); raw.dedup(); @@ -1631,7 +1704,7 @@ impl LanguageServer for Backend { }; Ok(Some(PrepareRenameResponse::Range(convert::span_to_range( &rope, - sym.span, + sym.span(), self.enc(), )))) } @@ -1653,11 +1726,31 @@ impl LanguageServer for Backend { let Ok(idx) = self.index.read() else { return Ok(None); }; - idx.reference_sites(sym.kind, &sym.name) - .iter() - .chain(idx.locations(sym.kind, &sym.name).iter()) - .map(|l| (l.file.clone(), l.span)) - .collect() + match &sym { + SymbolAt::Reference(sym) => idx + .reference_sites(sym.kind, &sym.name) + .iter() + .chain(idx.locations(sym.kind, &sym.name).iter()) + .map(|location| (location.file.clone(), location.span)) + .collect(), + SymbolAt::ModuleTag { symbol, before } => { + let definitions = if before.is_some() { + idx.effective_module_tag_locations( + &symbol.object, + &symbol.name, + Some(uri.as_str()), + *before, + ) + } else { + idx.module_tag_locations(&symbol.object, &symbol.name) + }; + idx.module_tag_reference_locations(&symbol.object, &symbol.name) + .into_iter() + .chain(definitions) + .map(|location| (location.file.clone(), location.span)) + .collect() + } + } }; raw.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.start.cmp(&b.1.start))); raw.dedup(); diff --git a/crates/server/src/scan.rs b/crates/server/src/scan.rs index 5128e0f..3429ece 100644 --- a/crates/server/src/scan.rs +++ b/crates/server/src/scan.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use tower_lsp::lsp_types::Url; use zerosyntax_analysis::index::{ definitions_in, module_tags_in, object_models_in, object_parents_in, references_in, AssetKind, - Definition, FileAsset, ModelAsset, ReferenceSite, + Definition, FileAsset, ModelAsset, ModuleTagDefinition, ReferenceSite, }; use zerosyntax_analysis::Analyzer; @@ -20,7 +20,7 @@ pub(crate) type ScanEntry = ( String, Vec, Vec, - Vec<(String, String)>, + Vec, Vec<(String, Vec)>, Vec<(String, String)>, Vec, @@ -28,7 +28,7 @@ pub(crate) type ScanEntry = ( Option>, ); -const INDEX_CACHE_VERSION: u32 = 2; +const INDEX_CACHE_VERSION: u32 = 4; #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] struct Fingerprint { @@ -42,7 +42,7 @@ struct CachedEntry { file: String, definitions: Vec, references: Vec, - tags: Vec<(String, String)>, + tags: Vec, object_models: Vec<(String, Vec)>, object_parents: Vec<(String, String)>, models: Vec, diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index a933838..056926b 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -307,6 +307,54 @@ def latest_burst_diag(message): "debounced burst diagnostics differ from a full-text baseline" print("OK: completion beats debounced diagnostics; burst publishes latest version only") + # RemoveModule tags navigate to the matching module tag on the same object. + module_map_uri = "file:///test/remove-module/map.ini" + open_doc( + module_map_uri, + "Object GotoTank\n" + " Behavior = DestroyDie ModuleTag_Target\n" + " End\n" + " RemoveModule ModuleTag_Target\n" + "End\n", + ) + send({"jsonrpc": "2.0", "id": 30, "method": "textDocument/definition", + "params": {"textDocument": {"uri": module_map_uri}, + "position": {"line": 3, "character": 16}}}) + definition = wait_for( + lambda m: m.get("id") == 30 and "result" in m, + "RemoveModule definition result", + ) + assert definition and definition["result"], "module tag did not resolve" + targets = definition["result"] + if isinstance(targets, dict): + targets = [targets] + assert targets[0]["uri"] == module_map_uri, targets + assert targets[0]["range"]["start"]["line"] == 1, targets + print("OK: RemoveModule tag resolves to its module definition") + + send({"jsonrpc": "2.0", "id": 70, "method": "textDocument/references", + "params": {"textDocument": {"uri": module_map_uri}, + "position": {"line": 3, "character": 16}, + "context": {"includeDeclaration": True}}}) + tag_refs = wait_for( + lambda m: m.get("id") == 70 and "result" in m, + "RemoveModule references result", + ) + assert sorted(location["range"]["start"]["line"] for location in tag_refs["result"]) == [1, 3] + send({"jsonrpc": "2.0", "id": 71, "method": "textDocument/rename", + "params": {"textDocument": {"uri": module_map_uri}, + "position": {"line": 1, "character": 30}, + "newName": "ModuleTag_Renamed"}}) + tag_rename = wait_for( + lambda m: m.get("id") == 71 and "result" in m, + "module tag rename result", + ) + tag_edits = tag_rename["result"]["changes"][module_map_uri] + assert len(tag_edits) == 2 and all( + edit["newText"] == "ModuleTag_Renamed" for edit in tag_edits + ), tag_edits + print("OK: module tag references and rename include declarations and removals") + cases = [ # (name, initial text, [(range, newText)], final text) ("value edit + field insert (multi-change batch)", diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 128b9b2..7d7f438 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -33,6 +33,7 @@ error-level syntax and schema problems when possible. | `unknown-field` | A field is not valid in the current block or module. | | `missing-module-tag` | A module is missing its required `ModuleTag_*` name. | | `unknown-module` | A module type is not known for the current module slot. | +| `unknown-module-tag` | A map or solo INI tries to remove a module tag not found on the existing object. | | `missing-condition` | A conditional state block is missing its condition token. | | `missing-value` | A field requires a value but none was provided. | | `bad-bool` | A boolean is not `Yes` or `No`. | @@ -53,6 +54,7 @@ error-level syntax and schema problems when possible. | `module-wrong-slot` | A module type is used under the wrong slot. | | `duplicate-module-tag` | Two modules in one object use the same module tag. | | `editor-default-module` | A placeholder module value should be replaced before shipping. | +| `default-modules-not-removed` | A newly created map or solo object still inherits modules from `DefaultThingTemplate`. | ## Quick fixes