Skip to content
221 changes: 199 additions & 22 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/schema", "crates/syntax", "crates/analysis", "crates/w3d", "crates/server"]

[workspace.package]
version = "1.3.0"
version = "1.3.1"
edition = "2021"
license = "MIT"
repository = "https://github.com/ViTeXFTW/ZeroSyntaxV2"
Expand Down Expand Up @@ -45,6 +45,9 @@ clap = { version = "4", default-features = false, features = [
"usage",
"error-context",
] }
rusqlite = { version = "0.31", features = ["bundled"] }
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
blake3 = "=1.5.5"

# benches
criterion = "0.8"
111 changes: 106 additions & 5 deletions crates/analysis/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ pub fn complete(
index,
(file, offset),
),
PosContext::ModuleName { slot_accepts } => module_name_completions(analyzer, &slot_accepts),
PosContext::ModuleName {
scope_node,
slot_accepts,
} => module_name_completions(analyzer, &scope_node, &slot_accepts),
PosContext::SubBlockArg { argument_type } => {
completions_for_type(analyzer, &argument_type, 0, None, None, index)
}
Expand All @@ -93,8 +96,10 @@ enum PosContext {
first_token: Option<String>,
},
/// Completing a module type name after a slot `=`. Carries the slot's
/// accepted interfaces so completions can be filtered to valid modules only.
/// enclosing scope so its snippet can choose an unused-looking module tag,
/// and accepted interfaces so candidates can be filtered to valid modules.
ModuleName {
scope_node: SyntaxNode,
slot_accepts: Vec<String>,
},
/// Completing the argument of a sub-block header.
Expand Down Expand Up @@ -189,7 +194,8 @@ fn classify_position(analyzer: &Analyzer, root: &SyntaxNode, offset: u32) -> Pos
// (before any nested field/scope) and after `=`, and the slot is a real
// module slot of the parent block.
if on_header_line(&module_node, offset) && after_equals(&module_node, offset) {
let parent = enclosing_scope(&module_node).map(|p| scope_schema(analyzer, &p));
let scope_node = enclosing_scope(&module_node);
let parent = scope_node.as_ref().map(|p| scope_schema(analyzer, p));
let slot = Module(module_node.clone()).slot();
let slot_accepts = slot.as_ref().and_then(|s| {
parent.as_ref().and_then(|p| {
Expand All @@ -201,6 +207,7 @@ fn classify_position(analyzer: &Analyzer, root: &SyntaxNode, offset: u32) -> Pos
});
if let Some(accepts) = slot_accepts {
return PosContext::ModuleName {
scope_node: scope_node.unwrap_or_else(|| root.clone()),
slot_accepts: accepts,
};
}
Expand Down Expand Up @@ -790,7 +797,12 @@ fn top_level_completions(analyzer: &Analyzer) -> Vec<Completion> {
.collect()
}

fn module_name_completions(analyzer: &Analyzer, slot_accepts: &[String]) -> Vec<Completion> {
fn module_name_completions(
analyzer: &Analyzer,
scope_node: &SyntaxNode,
slot_accepts: &[String],
) -> Vec<Completion> {
let tag = next_module_tag(analyzer, scope_node);
analyzer
.schema()
.modules
Expand All @@ -801,7 +813,7 @@ fn module_name_completions(analyzer: &Analyzer, slot_accepts: &[String]) -> Vec<
.map(|m| {
// Snippet: module name + placeholder tag + indented body + End.
// Also satisfies missing-module-tag in one accept.
let insert = Some(format!("{} ${{1:ModuleTag_01}}\n\t$0\nEnd", m.name));
let insert = Some(format!("{} ${{1:{tag}}}\n\t$0\nEnd", m.name));
Completion {
label: m.name.clone(),
kind: CompletionKind::Module,
Expand All @@ -812,6 +824,49 @@ fn module_name_completions(analyzer: &Analyzer, slot_accepts: &[String]) -> Vec<
.collect()
}

/// Suggest the next numeric tag used by module slots in the enclosing Object.
///
/// Descriptive tags (such as `ModuleTag_Draw`) deliberately do not affect the
/// numeric sequence. Only genuine module slots are considered: sub-block
/// headers can also have several arguments, but those arguments are not tags.
fn next_module_tag(analyzer: &Analyzer, scope_node: &SyntaxNode) -> String {
const PREFIX: &str = "ModuleTag_";
let object_node = scope_node
.ancestors()
.find(|node| {
Block(node.clone())
.keyword()
.is_some_and(|keyword| keyword.text().eq_ignore_ascii_case("Object"))
})
.unwrap_or_else(|| scope_node.clone());
let highest = object_node
.descendants()
.filter_map(Module::cast)
.filter(|module| {
let parent = enclosing_scope(&module.0);
let module_slots = parent
.as_ref()
.map(|parent| scope_schema(analyzer, parent).module_slots())
.unwrap_or_default();
module.slot().is_some_and(|slot| {
module_slots
.iter()
.any(|module_slot| module_slot.keyword.eq_ignore_ascii_case(slot.text()))
})
})
.filter_map(|module| module.tag())
.filter_map(|tag| {
let text = tag.text();
text.get(..PREFIX.len())
.filter(|prefix| prefix.eq_ignore_ascii_case(PREFIX))
.and_then(|_| text.get(PREFIX.len()..))
.and_then(|number| number.parse::<u64>().ok())
})
.max()
.unwrap_or(0);
format!("ModuleTag_{:02}", highest + 1)
}

// --- position helpers ---

fn ancestor_of_kind(node: &SyntaxNode, kind: SyntaxKind) -> Option<SyntaxNode> {
Expand Down Expand Up @@ -1007,6 +1062,52 @@ mod tests {
assert!(out.contains(&"ActiveBody".to_string()), "{out:?}");
}

#[test]
fn module_snippet_uses_next_numeric_tag_in_object() {
let src = "Object Tank\n Draw = W3DTankDraw ModuleTag_01\n End\n Behavior = SlowDeathBehavior MODULETAG_03\n End\n Behavior = \nEnd\n";
let offset = "Object Tank\n Draw = W3DTankDraw ModuleTag_01\n End\n Behavior = SlowDeathBehavior MODULETAG_03\n End\n Behavior = ".len() as u32;
let completion = item(src, offset, "AutoHealBehavior");
assert_eq!(
completion.insert.as_deref(),
Some("AutoHealBehavior ${1:ModuleTag_04}\n\t$0\nEnd")
);
}

#[test]
fn module_snippet_ignores_descriptive_tags_and_sub_block_arguments() {
let src = "Object Tank\n Draw = W3DTankDraw ModuleTag_Draw\n ConditionState = DAMAGED REALLYDAMAGED\n End\n End\n Behavior = \nEnd\n";
let offset = "Object Tank\n Draw = W3DTankDraw ModuleTag_Draw\n ConditionState = DAMAGED REALLYDAMAGED\n End\n End\n Behavior = ".len() as u32;
let completion = item(src, offset, "AutoHealBehavior");
assert_eq!(
completion.insert.as_deref(),
Some("AutoHealBehavior ${1:ModuleTag_01}\n\t$0\nEnd")
);
}

#[test]
fn module_snippet_in_reentrant_scope_uses_object_wide_sequence() {
let src = "Object Tank\n AddModule\n Behavior = SlowDeathBehavior ModuleTag_04\n End\n Behavior = \n End\nEnd\n";
let offset = "Object Tank\n AddModule\n Behavior = SlowDeathBehavior ModuleTag_04\n End\n Behavior = ".len() as u32;
let completion = item(src, offset, "AutoHealBehavior");
assert_eq!(
completion.insert.as_deref(),
Some("AutoHealBehavior ${1:ModuleTag_05}\n\t$0\nEnd")
);
}

#[test]
fn module_snippet_continues_past_u32_tag_values() {
let src = "Object Tank\n Behavior = SlowDeathBehavior ModuleTag_4294967295\n End\n Behavior = \nEnd\n";
let offset =
"Object Tank\n Behavior = SlowDeathBehavior ModuleTag_4294967295\n End\n Behavior = "
.len() as u32;
let completion = item(src, offset, "AutoHealBehavior");
assert_eq!(
completion.insert.as_deref(),
Some("AutoHealBehavior ${1:ModuleTag_4294967296}\n\t$0\nEnd")
);
}

#[test]
fn model_asset_completions_use_index() {
let a = Analyzer::embedded();
Expand Down
19 changes: 19 additions & 0 deletions crates/analysis/src/semantic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ impl<'a> Sem<'a> {
);
continue;
}
// Coordinates are a single schema value expressed as several raw
// syntax tokens (`X:… Y:… [Z:…]`). Highlight every axis alike.
if matches!(active_ty, Some(ValueType::Coord2D | ValueType::Coord3D)) {
self.set(tok, SemKind::Number);
continue;
}
// Token lists classify each position by its own element type.
let elem = active_ty.and_then(|ty| ty.token_type_at_input(&input, i));
self.set(tok, value_token_kind(tok, elem));
Expand Down Expand Up @@ -333,6 +339,19 @@ mod tests {
}
}

#[test]
fn classifies_coordinate_axes_as_numbers() {
let src = "Object Test\n Behavior = DefaultProductionExitUpdate ModuleTag_07\n NaturalRallyPoint = X:0.0 Y:-60.0 Z:0.0\n End\nEnd\n";
let t = toks(src);
for axis in ["X:0.0", "Y:-60.0", "Z:0.0"] {
assert!(
t.iter()
.any(|(kind, text)| *kind == SemKind::Number && text == axis),
"{axis} was not classified as a number"
);
}
}

#[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";
Expand Down
3 changes: 3 additions & 0 deletions crates/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ tracing-subscriber.workspace = true
anyhow.workspace = true
walkdir.workspace = true
clap.workspace = true
rusqlite.workspace = true
postcard.workspace = true
blake3.workspace = true
26 changes: 2 additions & 24 deletions crates/server/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1391,19 +1391,7 @@ impl Backend {
}

pub async fn index_cache_path(&self) -> Result<String> {
let roots = self
.roots
.lock()
.map(|roots| roots.clone())
.unwrap_or_default();
let base_roots = self
.settings
.lock()
.map(|settings| settings.base_ini_roots.clone())
.unwrap_or_default();
Ok(index_cache_path(&roots, &base_roots)
.to_string_lossy()
.into_owned())
Ok(index_cache_path().to_string_lossy().into_owned())
}

/// The (kind, name, span) under the cursor — a reference-typed value token
Expand Down Expand Up @@ -1704,16 +1692,6 @@ impl LanguageServer for Backend {
{
return Ok(None);
}
let roots = self
.roots
.lock()
.map(|roots| roots.clone())
.unwrap_or_default();
let base_roots = self
.settings
.lock()
.map(|settings| settings.base_ini_roots.clone())
.unwrap_or_default();
let mut progress = if params.command == REBUILD_INDEX_CACHE_COMMAND {
Some(self.begin_progress(ProgressWork::ManualRebuild).await)
} else {
Expand All @@ -1724,7 +1702,7 @@ impl LanguageServer for Backend {
.report("Clearing the persistent index cache", Some(0))
.await;
}
let cleared = match clear_index_cache(&roots, &base_roots) {
let cleared = match clear_index_cache() {
Ok(cleared) => cleared,
Err(error) => {
tracing::error!(%error, "asset index cache clear failed");
Expand Down
Loading