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
100 changes: 67 additions & 33 deletions crates/analysis/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode};

use crate::index::AssetKind;
use crate::model::{
is_model_asset_type, is_model_member_type, model_member_ini_name, models_for_source,
scope_schema,
is_model_asset_type, is_model_member_type, model_member_mode, model_member_names,
models_for_source, scope_schema,
};
use crate::{Analyzer, WorkspaceIndex};

Expand Down Expand Up @@ -374,10 +374,18 @@ fn field_value_completions(
if let Some(asset_completions) = model_asset_completions(
analyzer,
scope_node,
&f.value_type,
value_index,
f,
(value_index, current_token, first_token),
index,
f.model_source.as_ref(),
scope_node
.children()
.find(|node| {
node.kind() == SyntaxKind::FIELD
&& u32::from(node.text_range().start()) <= offset
&& offset <= u32::from(node.text_range().end())
})
.map(Field)
.as_ref(),
) {
asset_completions
} else {
Expand Down Expand Up @@ -410,16 +418,24 @@ fn field_value_completions(
fn model_asset_completions(
analyzer: &Analyzer,
scope_node: &SyntaxNode,
ty: &ValueType,
value_index: usize,
field_schema: &zerosyntax_schema::Field,
position: (usize, Option<&str>, Option<&str>),
index: Option<&WorkspaceIndex>,
source: Option<&zerosyntax_schema::ModelSource>,
field: Option<&Field>,
) -> Option<Vec<Completion>> {
let index = index?;
if !index.has_model_assets() {
return None;
}
let ty = token_value_type(ty, value_index);
let (value_index, current_token, first_token) = position;
let ty = field_schema
.value_type
.variant_for_first_token(first_token)?
.token_type_at(value_index)?;
let (ty, prefix) = match ty {
ValueType::Prefixed { prefix, value_type } => (value_type.as_ref(), Some(prefix)),
_ => (ty, None),
};
if is_model_asset_type(ty) {
return Some(
index
Expand All @@ -436,33 +452,51 @@ fn model_asset_completions(
if !is_model_member_type(ty) {
return None;
}
let mode = model_member_mode(field_schema.model_member_mode, field);
let mut seen = std::collections::HashSet::new();
let out = models_for_source(analyzer, scope_node, source, index)
.into_iter()
.flat_map(|model| {
index
.model_members(&model)
.map(|member| model_member_ini_name(member).to_string())
.collect::<Vec<_>>()
})
.filter(|member| seen.insert(member.to_ascii_lowercase()))
.map(|member| Completion {
label: member,
kind: CompletionKind::Reference,
detail: Some("W3D model member".into()),
insert: None,
})
.collect();
let out = models_for_source(
analyzer,
scope_node,
field_schema.model_source.as_ref(),
index,
)
.into_iter()
.flat_map(|model| {
index
.model_members(&model)
.flat_map(|member| {
model_member_names(member, mode)
.into_iter()
.map(move |name| {
(
name.to_string(),
mode.is_some() && name != member.rsplit('.').next().unwrap_or(member),
)
})
})
.collect::<Vec<_>>()
})
.filter(|(member, _)| seen.insert(member.to_ascii_lowercase()))
.map(|(member, family)| Completion {
insert: prefix
.filter(|prefix| {
!current_token
.and_then(|t| t.split_once(':'))
.is_some_and(|(actual, _)| actual.eq_ignore_ascii_case(prefix))
})
.map(|prefix| format!("{prefix}:{member}")),
detail: Some(if family {
"W3D numbered bone family (01, 02, ...)".into()
} else {
"W3D model member".into()
}),
label: member,
kind: CompletionKind::Reference,
})
.collect();
Some(out)
}

fn token_value_type(ty: &ValueType, value_index: usize) -> &ValueType {
match ty {
ValueType::TokenList { tokens } => tokens.get(value_index).unwrap_or(ty),
_ => ty,
}
}

/// Build a single-token snippet placeholder for a value type, used when
/// generating a full-sequence snippet for TokenList or structured types.
/// `n` is the tab-stop index (1-based).
Expand All @@ -472,7 +506,7 @@ fn type_snippet_placeholder(ty: &ValueType, n: usize) -> String {
if prefix.eq_ignore_ascii_case("Bone")
&& matches!(
value_type.as_ref(),
ValueType::AsciiString | ValueType::QuotedString
ValueType::AsciiString | ValueType::QuotedString | ValueType::W3dModelMember
)
{
format!("{prefix}:${{{n}:NONE}}")
Expand Down
61 changes: 21 additions & 40 deletions crates/analysis/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode, SyntaxToken};

use crate::index::{AssetKind, ModelMemberStrictness};
use crate::model::{
is_model_asset_type, is_model_member_type, model_member_matches, models_for_source,
module_fits_slot, scope_schema, ScopeSchema,
is_model_asset_type, is_model_member_type, model_member_matches, model_member_mode,
models_for_source, module_fits_slot, scope_schema, ScopeSchema,
};
use crate::{Analyzer, Span, WorkspaceIndex};

Expand Down Expand Up @@ -1093,43 +1093,22 @@ impl<'a> Ctx<'a> {
return;
}
let tokens = field.value_tokens();
match &schema_field.value_type {
ValueType::W3dModelList => {
for tok in &tokens {
self.validate_model_asset_token(
&schema_field.value_type,
tok,
scope_node,
schema_field.model_source.as_ref(),
);
}
}
ValueType::TokenList { tokens: specs } => {
let mut i = 0;
for spec in specs {
let Some(tok) = tokens.get(i) else { break };
let (ty, tok, consumed) = split_prefixed_token(&tokens[i..], spec)
.map(|(ty, tok)| (ty, tok, 2))
.unwrap_or((spec, tok, 1));
self.validate_model_asset_token(
ty,
tok,
scope_node,
schema_field.model_source.as_ref(),
);
i += consumed;
}
}
ty => {
if let Some(tok) = tokens.first() {
let (ty, tok) = split_prefixed_token(&tokens, ty).unwrap_or((ty, tok));
self.validate_model_asset_token(
ty,
tok,
scope_node,
schema_field.model_source.as_ref(),
);
}
let input = tokens.iter().map(|t| unquote(t.text())).collect::<Vec<_>>();
let mode = model_member_mode(schema_field.model_member_mode, Some(field));
for (i, tok) in tokens.iter().enumerate() {
let ty = if matches!(schema_field.value_type, ValueType::W3dModelList) {
Some(&schema_field.value_type)
} else {
schema_field.value_type.token_type_at_input(&input, i)
};
if let Some(ty) = ty {
self.validate_model_asset_token(
ty,
tok,
scope_node,
schema_field.model_source.as_ref(),
mode,
);
}
}
}
Expand Down Expand Up @@ -1197,6 +1176,7 @@ impl<'a> Ctx<'a> {
tok: &SyntaxToken,
scope_node: &SyntaxNode,
source: Option<&zerosyntax_schema::ModelSource>,
mode: Option<zerosyntax_schema::ModelMemberMode>,
) {
let Some(index) = self.index else { return };
let raw = unquote(tok.text());
Expand Down Expand Up @@ -1244,7 +1224,7 @@ impl<'a> Ctx<'a> {
checked_any_model = true;
if index
.model_members(&model)
.any(|member| model_member_matches(member, value))
.any(|member| model_member_matches(member, value, mode))
{
if index.model_member_strictness() == ModelMemberStrictness::Compatible {
return;
Expand Down Expand Up @@ -2560,6 +2540,7 @@ End
parse_fn: String::new(),
doc: None,
model_source: None,
model_member_mode: None,
};
for parser in eager {
field.parse_fn = parser.into();
Expand Down
63 changes: 61 additions & 2 deletions crates/analysis/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ pub(crate) fn is_model_member_type(ty: &ValueType) -> bool {
}

pub(crate) fn model_member_ini_name(member: &str) -> &str {
let member = member.rsplit('.').next().unwrap_or(member);
let trimmed = member.trim_end_matches(|c: char| c.is_ascii_digit());
if trimmed.is_empty() {
member
Expand All @@ -160,8 +161,66 @@ pub(crate) fn model_member_ini_name(member: &str) -> &str {
}
}

pub(crate) fn model_member_matches(member: &str, value: &str) -> bool {
member.eq_ignore_ascii_case(value) || model_member_ini_name(member).eq_ignore_ascii_case(value)
/// Resolve a contextual mode once, using the entire field (including tokens
/// after the cursor). Colon-separated values may occupy one or two tokens.
pub(crate) fn model_member_mode(
mode: Option<zerosyntax_schema::ModelMemberMode>,
field: Option<&AstField>,
) -> Option<zerosyntax_schema::ModelMemberMode> {
use zerosyntax_schema::ModelMemberMode;
if mode != Some(ModelMemberMode::RandomBone) {
return mode;
}
let tokens = field.map(AstField::value_tokens).unwrap_or_default();
for (i, token) in tokens.iter().enumerate() {
let Some((key, value)) = token.text().split_once(':') else {
continue;
};
if !key.eq_ignore_ascii_case("RandomBone") {
continue;
}
let value = if value.is_empty() {
tokens.get(i + 1).map(|t| t.text()).unwrap_or("")
} else {
value
};
if value.eq_ignore_ascii_case("Yes") {
return Some(ModelMemberMode::Indexed);
}
if value.eq_ignore_ascii_case("No") {
return Some(ModelMemberMode::Exact);
}
}
// While the controlling value is incomplete, offer both legal forms.
Some(ModelMemberMode::ExactOrIndexed)
}

pub(crate) fn model_member_names(
member: &str,
mode: Option<zerosyntax_schema::ModelMemberMode>,
) -> Vec<&str> {
use zerosyntax_schema::ModelMemberMode;
let short = member.rsplit('.').next().unwrap_or(member);
let base = short.strip_suffix("01").filter(|base| !base.is_empty());
match mode {
None => vec![model_member_ini_name(short)],
Some(ModelMemberMode::Exact) => vec![short],
Some(ModelMemberMode::Indexed) => base.into_iter().collect(),
Some(ModelMemberMode::ExactOrIndexed | ModelMemberMode::RandomBone) => {
std::iter::once(short).chain(base).collect()
}
}
}

pub(crate) fn model_member_matches(
member: &str,
value: &str,
mode: Option<zerosyntax_schema::ModelMemberMode>,
) -> bool {
(mode.is_none() && member.eq_ignore_ascii_case(value))
|| model_member_names(member, mode)
.iter()
.any(|name| name.eq_ignore_ascii_case(value))
}

/// Models referenced by W3D-model-typed fields visible from `scope_node`:
Expand Down
47 changes: 47 additions & 0 deletions crates/analysis/tests/bone_names.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use zerosyntax_analysis::{completion::complete, index::ModelAsset, Analyzer, WorkspaceIndex};

#[test]
fn bone_completions_deduplicate_aliases_and_preserve_prefix_insertion() {
let analyzer = Analyzer::embedded();
let mut index = WorkspaceIndex::new();
index.set_file_models(
"good.w3d",
vec![ModelAsset {
name: "Good".into(),
members: vec![
"Good.Fire01".into(),
"Fire01".into(),
"FIRE01".into(),
"Fire02".into(),
],
}],
);
for (value, expected_insert) in [
("$", Some("Bone:Fire01")),
("Bone:$", None),
("Bone: $", None),
("Bone: F$", None),
("Bone:F$", None),
] {
let marked = format!("Object Tank\n Draw = W3DModelDraw Tag\n DefaultConditionState\n Model = Good\n End\n End\n Behavior = TransitionDamageFX Damage\n DamagedParticleSystem1 = {value} RandomBone:No PSys:Smoke\n End\nEnd\n");
let offset = marked.find('$').unwrap();
let src = marked.replace('$', "");
let out = complete(
&analyzer,
&analyzer.parse(&src),
offset as u32,
Some(&index),
None,
);
let matches = out
.iter()
.filter(|item| item.label.eq_ignore_ascii_case("Fire01"))
.collect::<Vec<_>>();
assert_eq!(matches.len(), 1, "{value}: {out:?}");
assert_eq!(matches[0].insert.as_deref(), expected_insert, "{value}");
assert!(
!out.iter().any(|item| item.label.contains("Good.")),
"{value}: {out:?}"
);
}
}
18 changes: 18 additions & 0 deletions crates/analysis/tests/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ struct Spec {
audio_assets: Vec<String>,
#[serde(default)]
texture_assets: Vec<String>,
#[serde(default)]
model_assets: Vec<ModelSpec>,
}

#[derive(Deserialize)]
struct ModelSpec {
name: String,
members: Vec<String>,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -400,6 +408,16 @@ fn specs_hold() {
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_models(
"spec-models",
spec.model_assets
.iter()
.map(|model| zerosyntax_analysis::index::ModelAsset {
name: model.name.clone(),
members: model.members.clone(),
})
.collect(),
);
index.set_file_assets(
"spec-assets",
spec.audio_assets
Expand Down
Loading