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
79 changes: 78 additions & 1 deletion crates/analysis/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
//! * after `=` -> enum/bitflag members, `Yes`/`No`, module names, or (with the
//! workspace index) names of the referenced definition kind.

use zerosyntax_schema::ValueType;
use zerosyntax_schema::{AudioExtension, ValueType};
use zerosyntax_syntax::ast::{Block, Field, Module};
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,
Expand Down Expand Up @@ -494,6 +495,10 @@ fn type_snippet_placeholder(ty: &ValueType, n: usize) -> String {
ValueType::AsciiString | ValueType::AsciiStringList | ValueType::QuotedString => {
format!("${{{n}:Value}}")
}
ValueType::AudioFile { .. } => format!("${{{n}:Sound.wav}}"),
ValueType::AudioStemList => format!("${{{n}:Sound}}"),
ValueType::TextureFile => format!("${{{n}:Texture.tga}}"),
ValueType::TextureStem | ValueType::TextureSequenceStem => format!("${{{n}:Texture}}"),
ValueType::W3dModel | ValueType::W3dModelList => format!("${{{n}:Model}}"),
ValueType::W3dModelMember => format!("${{{n}:Bone}}"),
_ => format!("${{{n}:?}}"),
Expand Down Expand Up @@ -678,11 +683,83 @@ fn completions_for_type(
}));
out
}
ValueType::AudioFile { extension } => asset_completions(
index,
AssetKind::Audio,
"audio file",
|name| match extension {
AudioExtension::Any => Some(name.to_string()),
AudioExtension::Wav if has_extension(name, "wav") => Some(name.to_string()),
AudioExtension::Mp3 if has_extension(name, "mp3") => Some(name.to_string()),
_ => None,
},
),
ValueType::AudioStemList => {
asset_completions(index, AssetKind::Audio, "sound stem", |name| {
has_extension(name, "wav").then(|| file_stem(name).to_string())
})
}
ValueType::TextureFile => asset_completions(index, AssetKind::Texture, "texture", |name| {
Some(format!("{}.tga", file_stem(name)))
}),
ValueType::TextureStem => asset_completions(index, AssetKind::Texture, "texture", |name| {
Some(file_stem(name).to_string())
}),
ValueType::TextureSequenceStem => {
asset_completions(index, AssetKind::Texture, "texture", |name| {
let stem = file_stem(name);
if let Some(base) = stem.strip_suffix("0000") {
Some(base.to_string())
} else if stem
.as_bytes()
.get(stem.len().saturating_sub(4)..)
.is_some_and(|suffix| {
suffix.len() == 4 && suffix.iter().all(u8::is_ascii_digit)
})
{
None
} else {
Some(stem.to_string())
}
})
}
ValueType::W3dModel | ValueType::W3dModelList | ValueType::W3dModelMember => Vec::new(),
_ => Vec::new(),
}
}

fn file_stem(name: &str) -> &str {
name.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(name)
}

fn has_extension(name: &str, extension: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(_, actual)| actual.eq_ignore_ascii_case(extension))
}

fn asset_completions(
index: Option<&WorkspaceIndex>,
kind: AssetKind,
detail: &str,
label: impl Fn(&str) -> Option<String>,
) -> Vec<Completion> {
let Some(index) = index.filter(|index| index.has_assets(kind)) else {
return Vec::new();
};
let mut seen = std::collections::HashSet::new();
index
.asset_names(kind)
.filter_map(label)
.filter(|label| seen.insert(label.to_ascii_lowercase()))
.map(|label| Completion {
label,
kind: CompletionKind::Reference,
detail: Some(detail.to_string()),
insert: None,
})
.collect()
}

fn top_level_completions(analyzer: &Analyzer) -> Vec<Completion> {
analyzer
.schema()
Expand Down
128 changes: 126 additions & 2 deletions crates/analysis/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;

use zerosyntax_schema::{Field as SchemaField, RefKind, ValueType};
use zerosyntax_schema::{AudioExtension, Field as SchemaField, RefKind, ValueType};
use zerosyntax_syntax::ast::{Block, Field, Module};
use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode, SyntaxToken};

use crate::index::ModelMemberStrictness;
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,
Expand Down Expand Up @@ -96,6 +96,8 @@ pub const KNOWN_CODES: &[&str] = &[
"unresolved-reference",
"unknown-model",
"unknown-model-member",
"unknown-audio-file",
"unknown-texture",
"unknown-suppression",
"module-wrong-slot",
"duplicate-module-tag",
Expand Down Expand Up @@ -989,6 +991,7 @@ impl<'a> Ctx<'a> {
if let Some(schema_field) = scope.field(name) {
self.validate_value(&field, &schema_field.value_type);
self.validate_model_asset(&field, schema_field, scope_node);
self.validate_raw_asset(&field, &schema_field.value_type);
} else if scope.has_field_schema()
&& !scope.module_slots().iter().any(|s| s.keyword == name)
{
Expand Down Expand Up @@ -1052,6 +1055,63 @@ impl<'a> Ctx<'a> {
}
}

fn validate_raw_asset(&mut self, field: &Field, ty: &ValueType) {
let Some(index) = self.index else { return };
let tokens = field.value_tokens();
match ty {
ValueType::AudioFile { extension } if index.has_assets(AssetKind::Audio) => {
if let Some(token) = tokens.first() {
let name = unquote(token.text());
let allowed = match extension {
AudioExtension::Any => {
has_extension(name, "wav") || has_extension(name, "mp3")
}
AudioExtension::Wav => has_extension(name, "wav"),
AudioExtension::Mp3 => has_extension(name, "mp3"),
};
if !name.eq_ignore_ascii_case("None")
&& (!allowed || !index.is_asset(AssetKind::Audio, name))
{
self.warning(
token,
"unknown-audio-file",
format!("`{name}` is not a known audio file"),
);
}
}
}
ValueType::AudioStemList if index.has_assets(AssetKind::Audio) => {
for token in tokens {
let name = unquote(token.text());
if !name.eq_ignore_ascii_case("None")
&& !index.is_asset(AssetKind::Audio, &format!("{name}.wav"))
{
self.warning(
&token,
"unknown-audio-file",
format!("`{name}` is not a known WAV sound stem"),
);
}
}
}
ValueType::TextureFile | ValueType::TextureStem | ValueType::TextureSequenceStem
if index.has_assets(AssetKind::Texture) =>
{
if let Some(token) = tokens.first() {
let name = unquote(token.text());
if !name.eq_ignore_ascii_case("None") && !texture_exists(index, ty, name) {
self.warning(
token,
"unknown-texture",
format!("`{name}` is not a known texture"),
);
}
}
}
_ => {}
}
}

fn validate_model_asset_token(
&mut self,
ty: &ValueType,
Expand Down Expand Up @@ -1511,6 +1571,11 @@ impl<'a> Ctx<'a> {
| ValueType::W3dModel
| ValueType::W3dModelList
| ValueType::W3dModelMember
| ValueType::AudioFile { .. }
| ValueType::AudioStemList
| ValueType::TextureFile
| ValueType::TextureStem
| ValueType::TextureSequenceStem
| ValueType::Color
| ValueType::Coord2D
| ValueType::Coord3D
Expand Down Expand Up @@ -1831,6 +1896,30 @@ impl<'a> Ctx<'a> {
}
}

fn has_extension(name: &str, extension: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(_, actual)| actual.eq_ignore_ascii_case(extension))
}

fn texture_exists(index: &WorkspaceIndex, ty: &ValueType, name: &str) -> bool {
let exact = |candidate: &str| index.is_asset(AssetKind::Texture, candidate);
match ty {
ValueType::TextureFile if has_extension(name, "dds") => exact(name),
ValueType::TextureFile if has_extension(name, "tga") => {
exact(name) || exact(&format!("{}.dds", &name[..name.len() - 4]))
}
ValueType::TextureFile => false,
ValueType::TextureStem => exact(&format!("{name}.tga")) || exact(&format!("{name}.dds")),
ValueType::TextureSequenceStem => {
exact(&format!("{name}.tga"))
|| exact(&format!("{name}.dds"))
|| exact(&format!("{name}0000.tga"))
|| exact(&format!("{name}0000.dds"))
}
_ => false,
}
}

enum NumKind {
Int,
UInt,
Expand Down Expand Up @@ -2737,4 +2826,39 @@ End
.iter()
.any(|d| d.code == "unknown-model-member"));
}

#[test]
fn raw_asset_warnings_are_gated_per_kind() {
let a = Analyzer::embedded();
let src = "DialogEvent Dialog\n Filename = Missing.wav\nEnd\nMappedImage Image\n Texture = Missing.tga\nEnd\n";
let parse = a.parse(src);
let mut index = WorkspaceIndex::new();
let codes = |index: &WorkspaceIndex| {
diagnose(&a, &parse, Some(index), None)
.into_iter()
.map(|diagnostic| diagnostic.code)
.collect::<Vec<_>>()
};
assert!(!codes(&index)
.iter()
.any(|code| code.starts_with("unknown-")));
index.set_file_assets(
"audio",
vec![crate::index::FileAsset {
kind: AssetKind::Audio,
name: "Known.wav".into(),
}],
);
let audio_only = codes(&index);
assert!(audio_only.contains(&"unknown-audio-file"));
assert!(!audio_only.contains(&"unknown-texture"));
index.set_file_assets(
"texture",
vec![crate::index::FileAsset {
kind: AssetKind::Texture,
name: "Known.dds".into(),
}],
);
assert!(codes(&index).contains(&"unknown-texture"));
}
}
Loading