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
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,14 @@ schema.json ──embedded──▶ schema ──▶ analysis ──▶ server
`WorkspaceIndex`. `convert.rs` does byte-offset ↔ LSP position mapping in
the position encoding negotiated at `initialize` (UTF-8 when the client
offers it, else the UTF-16 baseline). Advertises `semanticTokens` `full`
**and** `range`. Formatting is **opt-in**: the capability is advertised
only when `initializationOptions` carries `{"format": {"enable": true}}`
(the VS Code setting `zerosyntax.format.enable`, default off — real game
files are wildly hand-indented, so format-on-save must never fire
unasked). Phase-3 numbers (`docs/phase3-incremental.md`): keystroke on the
**and** `range`. Runtime settings arrive through initialization options and
`workspace/didChangeConfiguration`; analysis switches refresh open docs,
while schema/base-root changes replace the complete index and reparse only
when the schema changes. Formatting is **opt-in** and dynamically registered
when supported (the VS Code setting `zerosyntax.format.enable`, default off —
real game files are wildly hand-indented, so format-on-save must never fire
unasked). Only the executable-path setting requires a client restart.
Phase-3 numbers (`docs/phase3-incremental.md`): keystroke on the
61k-line ParticleSystem.ini ≈ 147 µs vs 44 ms full reparse.

### Two concepts worth understanding before editing
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 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/server"]

[workspace.package]
version = "1.2.0"
version = "1.2.3"
edition = "2021"
license = "MIT"
repository = "https://github.com/ViTeXFTW/ZeroSyntaxV2"
Expand Down
21 changes: 7 additions & 14 deletions crates/analysis/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use zerosyntax_schema::{RefKind, ValueType};
use zerosyntax_syntax::ast::{Field, Module};
use zerosyntax_syntax::{Parse, SyntaxErrorKind, SyntaxKind, SyntaxNode, SyntaxToken};

use crate::diagnostics::{pragma_rest, pragma_words, Diagnostic, Severity};
use crate::diagnostics::{pragma_rest, pragma_words, Diagnostic};
use crate::model::scope_schema;
use crate::{nav, Analyzer, Span, WorkspaceIndex};

Expand Down Expand Up @@ -203,12 +203,8 @@ fn diagnostic_fixes(
}
_ => {}
}
// Suppress pragma: warnings and hints only; never errors, never the
// misspelled-suppression hint (suppressing it is self-defeating).
if d.severity != Severity::Error
&& d.code != "unknown-suppression"
&& suppress_seen.insert(d.code)
{
// Suppressing the misspelled-suppression hint is self-defeating.
if d.code != "unknown-suppression" && suppress_seen.insert(d.code) {
suppress_fix(parse, text, d.code, out);
}
}
Expand Down Expand Up @@ -429,7 +425,7 @@ fn stub_keyword<'a>(analyzer: &'a Analyzer, kind: RefKind) -> Option<&'a str> {
}

/// Offer to add `; zerosyntax-disable: <code>` at the top of the file (or
/// append to an existing pragma line) for warning/hint diagnostics.
/// append to an existing pragma line) for a diagnostic.
fn suppress_fix(parse: &Parse, text: &str, code: &'static str, out: &mut Vec<Fix>) {
let root = parse.syntax();
let mut first_pragma: Option<(u32, bool)> = None; // (insert offset, has_any_codes)
Expand Down Expand Up @@ -923,17 +919,14 @@ mod tests {
}

#[test]
fn suppress_fix_severity_and_dedupe_rules() {
// bad-bool is Error severity → no Suppress action.
fn suppress_fix_errors_and_dedupes() {
// Error diagnostics get the same Suppress action as warnings.
let src_err = "Weapon W\n ScaleWeaponSpeed = Maybe\nEnd\n";
let fx_err = all_fixes(src_err);
let has_suppress_for_error = fx_err
.iter()
.any(|f| f.title.contains("Suppress") && f.title.contains("bad-bool"));
assert!(
!has_suppress_for_error,
"errors must not get suppress: {fx_err:?}"
);
assert!(has_suppress_for_error, "error missing suppress: {fx_err:?}");

// Two unresolved references with the same code → only one Suppress action.
let src_dup = "Weapon W\n FireFX = NoSuchFX\n FireFX = NoSuchFX2\nEnd\n";
Expand Down
124 changes: 121 additions & 3 deletions 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,14 +495,21 @@ fn type_snippet_placeholder(ty: &ValueType, n: usize) -> String {
ValueType::AsciiString | ValueType::AsciiStringList | ValueType::QuotedString => {
format!("${{{n}:Value}}")
}
ValueType::W3dModel => format!("${{{n}:Model}}"),
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}:?}}"),
}
}

fn value_snippet(ty: &ValueType) -> Option<String> {
match ty {
ValueType::RandomVariable { .. } => Some("${1:0} ${2:0}$0".into()),
ValueType::RandomKeyframe => Some("${1:0} ${2:0} ${3:0}$0".into()),
ValueType::ColorKeyframe => Some("R:${1:0} G:${2:0} B:${3:0} ${4:0}$0".into()),
ValueType::TokenList { tokens } if tokens.len() > 1 => {
let mut snippet = tokens
.iter()
Expand All @@ -526,6 +534,19 @@ fn completions_for_type(
index: Option<&WorkspaceIndex>,
) -> Vec<Completion> {
match ty {
ValueType::RandomVariable { value_set } if value_index == 2 => completions_for_type(
analyzer,
&ValueType::Enum {
value_set: value_set.clone(),
},
0,
current_token,
first_token,
index,
),
ValueType::RandomVariable { .. } | ValueType::RandomKeyframe | ValueType::ColorKeyframe => {
Vec::new()
}
ValueType::OneOf { variants } => {
if current_token.is_some() || value_index > 0 {
return ty
Expand Down Expand Up @@ -662,11 +683,83 @@ fn completions_for_type(
}));
out
}
ValueType::W3dModel | ValueType::W3dModelMember => Vec::new(),
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 Expand Up @@ -775,7 +868,11 @@ fn type_label(ty: &ValueType) -> String {
ValueType::BitFlags { value_set } => format!("flags {value_set}"),
ValueType::Reference { ref_kind } => format!("ref {ref_kind:?}"),
ValueType::W3dModel => "w3d model".into(),
ValueType::W3dModelList => "w3d models".into(),
ValueType::W3dModelMember => "w3d model member".into(),
ValueType::RandomVariable { .. } => "real real [distribution]".into(),
ValueType::RandomKeyframe => "real real frame".into(),
ValueType::ColorKeyframe => "R: G: B: frame".into(),
ValueType::Prefixed { prefix, value_type } => {
format!("{prefix}:{}", type_label(value_type))
}
Expand Down Expand Up @@ -967,6 +1064,27 @@ End
assert!(out.contains(&"WeaponA".to_string()), "{out:?}");
}

#[test]
fn ocl_model_list_completes_every_position() {
let a = Analyzer::embedded();
let mut index = WorkspaceIndex::new();
index.set_file_models(
"models/Good.w3d",
vec![crate::index::ModelAsset {
name: "Good".into(),
members: vec![],
}],
);
let src =
"ObjectCreationList Debris\n CreateDebris\n ModelNames = First \n End\nEnd\n";
let offset = src.find("First ").unwrap() + "First ".len();
let out = complete(&a, &a.parse(src), offset as u32, Some(&index), None)
.into_iter()
.map(|item| item.label)
.collect::<Vec<_>>();
assert!(out.contains(&"Good".to_string()), "{out:?}");
}

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