diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 709ca61..c4fb66a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: Swatinem/rust-cache@v2 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: 22 cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5629bb6..f72a185 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -179,7 +179,7 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ needs.prepare.outputs.dev_sha }} - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: 22 cache: npm @@ -296,7 +296,7 @@ jobs: if [ -n "$VSCE_PAT" ]; then echo "present=true" >> "$GITHUB_OUTPUT"; fi - uses: actions/checkout@v7 if: steps.token.outputs.present == 'true' - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 if: steps.token.outputs.present == 'true' with: node-version: 22 diff --git a/.gitignore b/.gitignore index 880081c..83669f0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ !.github/** !editors/vscode/.vscodeignore +# Shared VS Code run/debug config for the local dev workflow (see docs/vscode-development.md) +!.vscode/ +!.vscode/launch.json +!.vscode/tasks.json + /target **/*.rs.bk diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a65fb41 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension (local dev server)", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}/editors/vscode"], + "outFiles": ["${workspaceFolder}/editors/vscode/out/**/*.js"], + "preLaunchTask": "build-dev", + "windows": { + "env": { + "ZEROSYNTAX_LSP_PATH": "${workspaceFolder}\\target\\debug\\zerosyntax-lsp.exe", + "RUST_LOG": "zerosyntax_lsp=debug" + } + }, + "linux": { + "env": { + "ZEROSYNTAX_LSP_PATH": "${workspaceFolder}/target/debug/zerosyntax-lsp", + "RUST_LOG": "zerosyntax_lsp=debug" + } + }, + "osx": { + "env": { + "ZEROSYNTAX_LSP_PATH": "${workspaceFolder}/target/debug/zerosyntax-lsp", + "RUST_LOG": "zerosyntax_lsp=debug" + } + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..aa3b5e5 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-server", + "type": "shell", + "command": "cargo build -p zerosyntax-server", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": "$rustc", + "presentation": { "reveal": "silent", "clear": true, "panel": "shared" }, + "group": "build" + }, + { + "label": "build-extension", + "type": "shell", + "command": "npm run compile:dev", + "options": { "cwd": "${workspaceFolder}/editors/vscode" }, + "problemMatcher": "$tsc", + "presentation": { "reveal": "silent", "panel": "shared" }, + "group": "build" + }, + { + "label": "build-dev", + "dependsOn": ["build-server", "build-extension"], + "dependsOrder": "parallel", + "group": { "kind": "build", "isDefault": true } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 5ea7526..351ff11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,3 +212,7 @@ any crate. - Adding/changing diagnostics or schema content: update the affected `tests/spec/*.spec.toml` (and drop any `xfail` a change now satisfies), then review the diff. +- Server logging uses `window/logMessage` for concise user-facing lifecycle + events and `tracing` for developer detail. Keep stdout protocol-only, put + paths/URIs at Debug or Trace, never log source text or INI values, and use + structured `snake_case` fields with unit suffixes such as `_ms` and `_bytes`. diff --git a/Cargo.lock b/Cargo.lock index f39e5e9..be447bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -34,9 +40,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-trait" @@ -66,6 +72,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -84,6 +96,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.0" @@ -141,18 +165,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstyle", "clap_lex", @@ -164,12 +188,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "countme" version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.8.2" @@ -296,12 +335,31 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -393,6 +451,12 @@ dependencies = [ "slab", ] +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + [[package]] name = "half" version = "2.7.1" @@ -525,6 +589,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -654,6 +731,16 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -788,6 +875,19 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -954,9 +1054,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1010,6 +1110,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1103,9 +1209,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1144,9 +1250,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -1168,18 +1274,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -1528,7 +1634,7 @@ dependencies = [ [[package]] name = "zerosyntax-analysis" -version = "1.2.3" +version = "1.3.0" dependencies = [ "criterion", "rowan", @@ -1540,7 +1646,7 @@ dependencies = [ [[package]] name = "zerosyntax-schema" -version = "1.2.3" +version = "1.3.0" dependencies = [ "serde", "serde_json", @@ -1548,11 +1654,13 @@ dependencies = [ [[package]] name = "zerosyntax-server" -version = "1.2.3" +version = "1.3.0" dependencies = [ "anyhow", + "base64", "clap", "dashmap 6.2.1", + "percent-encoding", "ropey", "serde", "serde_json", @@ -1564,17 +1672,26 @@ dependencies = [ "zerosyntax-analysis", "zerosyntax-schema", "zerosyntax-syntax", + "zerosyntax-w3d", ] [[package]] name = "zerosyntax-syntax" -version = "1.2.3" +version = "1.3.0" dependencies = [ "criterion", "logos", "rowan", ] +[[package]] +name = "zerosyntax-w3d" +version = "1.3.0" +dependencies = [ + "glam", + "image", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index bd6272b..db8078f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] resolver = "2" -members = ["crates/schema", "crates/syntax", "crates/analysis", "crates/server"] +members = ["crates/schema", "crates/syntax", "crates/analysis", "crates/w3d", "crates/server"] [workspace.package] -version = "1.2.3" +version = "1.3.0" edition = "2021" license = "MIT" repository = "https://github.com/ViTeXFTW/ZeroSyntaxV2" @@ -13,6 +13,7 @@ rust-version = "1.75" zerosyntax-schema = { path = "crates/schema" } zerosyntax-syntax = { path = "crates/syntax" } zerosyntax-analysis = { path = "crates/analysis" } +zerosyntax-w3d = { path = "crates/w3d" } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -31,6 +32,10 @@ tower-lsp = "0.20" tokio = { version = "1", features = ["full"] } ropey = "1" dashmap = "6" +percent-encoding = "2" +base64 = "0.22" +glam = "0.27" +image = { version = "0.24.9", default-features = false, features = ["dds", "png", "tga"] } # server (workspace indexing) walkdir = "2" diff --git a/README.md b/README.md index d41bfee..28dd129 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ VS Code extension with the server bundled. - Optional indentation formatting, disabled by default so existing files are never reformatted without your consent. - Base-game and mod indexing for `map.ini` and `solo.ini`, including W3D model - and bone checks. + and bone checks. Selecting a `Model =` completion shows a textured W3D + thumbnail when the indexed asset is available, with configurable size and + zoom. ## Install the VS Code extension diff --git a/crates/analysis/Cargo.toml b/crates/analysis/Cargo.toml index a252f8c..1d7ce6f 100644 --- a/crates/analysis/Cargo.toml +++ b/crates/analysis/Cargo.toml @@ -8,9 +8,9 @@ license.workspace = true zerosyntax-schema.workspace = true zerosyntax-syntax.workspace = true rowan.workspace = true +serde.workspace = true [dev-dependencies] -serde.workspace = true toml.workspace = true criterion.workspace = true diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index 120c78d..ccdb8c5 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -26,6 +26,7 @@ pub enum CompletionKind { EnumMember, Value, Reference, + W3dModel, } /// A single completion candidate. @@ -68,7 +69,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 +114,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 +332,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 +345,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, @@ -413,7 +419,7 @@ fn model_asset_completions( .model_names() .map(|name| Completion { label: name.to_string(), - kind: CompletionKind::Reference, + kind: CompletionKind::W3dModel, detail: Some("W3D model".into()), insert: None, }) @@ -950,6 +956,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..b6e3a70 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(); @@ -2863,6 +3006,7 @@ End vec![crate::index::FileAsset { kind: AssetKind::Audio, name: "Known.wav".into(), + uri: "file:///Known.wav".into(), }], ); let audio_only = codes(&index); @@ -2873,6 +3017,7 @@ End vec![crate::index::FileAsset { kind: AssetKind::Texture, name: "Known.dds".into(), + uri: "file:///Known.dds".into(), }], ); assert!(codes(&index).contains(&"unknown-texture")); diff --git a/crates/analysis/src/index.rs b/crates/analysis/src/index.rs index 4ccaebb..d035d4a 100644 --- a/crates/analysis/src/index.rs +++ b/crates/analysis/src/index.rs @@ -6,7 +6,9 @@ //! change ([`WorkspaceIndex::set_file`]). use std::collections::HashMap; +use std::sync::Arc; +use serde::{Deserialize, Serialize}; use zerosyntax_schema::{RefKind, ValueType}; use zerosyntax_syntax::ast::{Block, Field, Module}; use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode, SyntaxToken}; @@ -14,20 +16,21 @@ use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode, SyntaxToken}; use crate::model::{scope_schema, ScopeSchema}; use crate::{Analyzer, Span}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum AssetKind { Audio, Texture, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FileAsset { pub kind: AssetKind, pub name: String, + pub uri: String, } /// Model data discovered from a W3D asset. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelAsset { pub name: String, pub members: Vec, @@ -41,7 +44,7 @@ pub struct Location { } /// A named definition discovered in a document. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Definition { pub name: String, pub kind: RefKind, @@ -49,13 +52,22 @@ pub struct Definition { } /// A place where a definition is *referenced* (a Reference-typed field value). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReferenceSite { pub name: String, pub kind: RefKind, 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, @@ -71,6 +83,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 @@ -94,9 +111,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>, @@ -104,10 +123,11 @@ pub struct WorkspaceIndex { /// keeps the per-file contributions, so re-indexing or removing one asset /// file (e.g. a patch archive overriding a base-game model) never drops /// another file's model of the same name. - model_assets: HashMap>, + model_assets: HashMap, ModelAsset)>>, /// Reverse map for removing/replacing models contributed by one asset file. - file_models: HashMap>, - asset_names: HashMap>>, + file_models: HashMap>, + asset_names: HashMap>>, + texture_assets: HashMap>, file_assets: HashMap>, object_models: HashMap)>>, file_object_models: HashMap)>>, @@ -202,6 +222,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; } @@ -211,6 +232,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) { @@ -243,35 +265,69 @@ impl WorkspaceIndex { /// Replace W3D model assets contributed by `file`. pub fn set_file_models(&mut self, file: &str, models: Vec) { - let normalized = normalized_model_assets(&models); let changed = match self.file_models.get(file) { - Some(old) => normalized_model_assets(old) != normalized, + Some(old) => { + let old = old + .iter() + .filter_map(|name| self.model_assets.get(name)) + .flatten() + .filter(|(source, _)| source.as_ref() == file) + .map(|(_, model)| model) + .collect::>(); + normalized_model_asset_refs(&old) != normalized_model_assets(&models) + } None => !models.is_empty(), }; if changed { self.generation += 1; } self.remove_model_entries(file); + let source: Arc = Arc::from(file); let mut stored = Vec::with_capacity(models.len()); for mut model in models { dedup_case_insensitive(&mut model.members); + let lower = model.name.to_ascii_lowercase(); self.model_assets - .entry(model.name.to_ascii_lowercase()) + .entry(lower.clone()) .or_default() - .push((file.to_string(), model.clone())); - stored.push(model); + .push((source.clone(), model)); + stored.push(lower); } if !stored.is_empty() { self.file_models.insert(file.to_string(), stored); } } + /// Insert pre-normalized W3D models while constructing a fresh index. + /// + /// Unlike [`set_file_models`](Self::set_file_models), this avoids change + /// detection and member normalization. Callers must supply a file that is + /// not already present and model members already deduplicated + /// case-insensitively (the W3D catalog guarantees this). + pub fn insert_file_models_prepared(&mut self, file: &str, models: Vec) { + debug_assert!(!self.file_models.contains_key(file)); + if models.is_empty() { + return; + } + self.generation += 1; + let source: Arc = Arc::from(file); + let mut stored = Vec::with_capacity(models.len()); + for model in models { + let lower = model.name.to_ascii_lowercase(); + self.model_assets + .entry(lower.clone()) + .or_default() + .push((source.clone(), model)); + stored.push(lower); + } + self.file_models.insert(file.to_string(), stored); + } + fn remove_model_entries(&mut self, file: &str) { if let Some(models) = self.file_models.remove(file) { - for model in models { - let lower = model.name.to_ascii_lowercase(); + for lower in models { if let Some(contribs) = self.model_assets.get_mut(&lower) { - contribs.retain(|(f, _)| f != file); + contribs.retain(|(source, _)| source.as_ref() != file); if contribs.is_empty() { self.model_assets.remove(&lower); } @@ -302,7 +358,13 @@ impl WorkspaceIndex { .or_default() .entry(asset.name.to_ascii_lowercase()) .or_default() - .push((file.to_string(), asset.name.clone())); + .push((file.to_string(), asset.clone())); + if asset.kind == AssetKind::Texture { + self.texture_assets + .entry(asset_stem(&asset.name)) + .or_default() + .push((file.to_string(), asset.clone())); + } } if assets.is_empty() { self.file_assets.remove(file); @@ -334,6 +396,15 @@ impl WorkspaceIndex { self.asset_names.remove(&asset.kind); } } + if asset.kind == AssetKind::Texture { + let stem = asset_stem(&asset.name); + if let Some(contribs) = self.texture_assets.get_mut(&stem) { + contribs.retain(|(source, _)| source != file); + if contribs.is_empty() { + self.texture_assets.remove(&stem); + } + } + } } } @@ -354,7 +425,7 @@ impl WorkspaceIndex { .get(&kind) .into_iter() .flat_map(|names| names.values().filter_map(|sources| sources.first())) - .map(|(_, display)| display.as_str()) + .map(|(_, asset)| asset.name.as_str()) } pub fn set_file_object_models(&mut self, file: &str, objects: Vec<(String, Vec)>) { @@ -450,37 +521,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`. @@ -515,10 +724,26 @@ impl WorkspaceIndex { pub fn model_names(&self) -> impl Iterator { self.model_assets .values() - .filter_map(|contribs| contribs.first()) + .filter_map(|contribs| contribs.last()) .map(|(_, m)| m.name.as_str()) } + /// Last indexed contributor wins: base roots are indexed first and + /// workspace roots last. + pub fn effective_model_source(&self, model: &str) -> Option<&str> { + self.model_assets + .get(&model.to_ascii_lowercase()) + .and_then(|contribs| contribs.last()) + .map(|(source, _)| source.as_ref()) + } + + pub fn effective_texture_source(&self, texture: &str) -> Option<&FileAsset> { + self.texture_assets + .get(&asset_stem(texture)) + .and_then(|contribs| contribs.last()) + .map(|(_, asset)| asset) + } + /// User-addressable members (pivots/subobjects/meshes) for `model`, /// across every file that contributes the model. May repeat a member /// when several files define the same model; callers dedup or use `any`. @@ -599,6 +824,10 @@ fn dedup_case_insensitive(values: &mut Vec) { } fn normalized_model_assets(models: &[ModelAsset]) -> Vec<(String, Vec)> { + normalized_model_asset_refs(&models.iter().collect::>()) +} + +fn normalized_model_asset_refs(models: &[&ModelAsset]) -> Vec<(String, Vec)> { let mut out = models .iter() .map(|model| { @@ -616,6 +845,14 @@ fn normalized_model_assets(models: &[ModelAsset]) -> Vec<(String, Vec)> out } +fn asset_stem(name: &str) -> String { + let file = name.rsplit(['/', '\\']).next().unwrap_or(name); + file.rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(file) + .to_ascii_lowercase() +} + fn normalize_object_models(objects: &[(String, Vec)]) -> Vec<(String, Vec)> { let mut out = objects .iter() @@ -633,6 +870,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 @@ -864,9 +1116,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 { @@ -881,7 +1133,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, + }); } } } @@ -945,6 +1218,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(); @@ -980,6 +1280,7 @@ mod tests { let audio = |name: &str| FileAsset { kind: AssetKind::Audio, name: name.into(), + uri: format!("file:///{name}"), }; let mut idx = WorkspaceIndex::new(); idx.set_file_assets("base", vec![audio("Click.WAV")]); @@ -1004,6 +1305,44 @@ mod tests { assert!(idx.is_asset(AssetKind::Audio, "OTHER.WAV")); } + #[test] + fn effective_model_and_texture_use_last_contributor() { + let texture = |name: &str, uri: &str| FileAsset { + kind: AssetKind::Texture, + name: name.into(), + uri: uri.into(), + }; + let mut idx = WorkspaceIndex::new(); + idx.set_file_models( + "base.w3d", + vec![ModelAsset { + name: "Tank".into(), + members: Vec::new(), + }], + ); + idx.set_file_models( + "mod.w3d", + vec![ModelAsset { + name: "TANK".into(), + members: Vec::new(), + }], + ); + idx.set_file_assets( + "base.big", + vec![texture("Tank.dds", "big:///base.big!/Tank.dds")], + ); + idx.set_file_assets( + "workspace", + vec![texture("tank.tga", "file:///workspace/tank.tga")], + ); + assert_eq!(idx.effective_model_source("tank"), Some("mod.w3d")); + assert_eq!( + idx.effective_texture_source("TANK.DDS") + .map(|asset| asset.uri.as_str()), + Some("file:///workspace/tank.tga") + ); + } + #[test] fn split_prefixed_reference_site_span_excludes_prefix() { let a = Analyzer::embedded(); @@ -1057,22 +1396,56 @@ mod tests { #[test] fn model_asset_member_changes_bump_generation() { let mut idx = WorkspaceIndex::new(); + let original = vec![ModelAsset { + name: "Tank".into(), + members: vec!["Tire01".into()], + }]; + idx.set_file_models("model.w3d", original.clone()); + let g1 = idx.generation(); + idx.set_file_models("model.w3d", original); + assert_eq!(idx.generation(), g1); + idx.set_file_models( "model.w3d", vec![ModelAsset { name: "Tank".into(), - members: vec!["Tire01".into()], + members: vec!["Tire02".into()], }], ); - let g1 = idx.generation(); - idx.set_file_models( - "model.w3d", + assert_ne!(idx.generation(), g1); + } + + #[test] + fn prepared_model_insert_supports_lookup_override_and_removal() { + let mut idx = WorkspaceIndex::new(); + idx.insert_file_models_prepared( + "base.w3d", vec![ModelAsset { name: "Tank".into(), - members: vec!["Tire02".into()], + members: vec!["Tire01".into()], }], ); - assert_ne!(idx.generation(), g1); + idx.insert_file_models_prepared( + "patch.w3d", + vec![ModelAsset { + name: "TANK".into(), + members: vec!["Cargo01".into()], + }], + ); + + assert!(idx.is_model_asset("tank")); + assert_eq!(idx.effective_model_source("Tank"), Some("patch.w3d")); + assert_eq!( + idx.model_members("tank").collect::>(), + vec!["Tire01", "Cargo01"] + ); + + idx.remove_file("patch.w3d"); + assert_eq!(idx.effective_model_source("tank"), Some("base.w3d")); + assert_eq!( + idx.model_members("tank").collect::>(), + vec!["Tire01"] + ); } #[test] diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index 9e57208..46cf5ec 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -8,6 +8,7 @@ use std::collections::{HashMap, HashSet}; +use serde::{Deserialize, Serialize}; use zerosyntax_schema::{BlockType, ModuleType, RefKind, Schema, ValueSet}; use zerosyntax_syntax::{parse, Edit, OpenerOracle, Parse, Strategy}; @@ -25,7 +26,7 @@ pub use diagnostics::{Diagnostic, Severity}; pub use index::WorkspaceIndex; /// A half-open byte range `[start, end)` into the source text. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Span { pub start: u32, pub end: u32, 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..385e9af 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 @@ -404,10 +407,12 @@ fn specs_hold() { .map(|name| FileAsset { kind: AssetKind::Audio, name: name.clone(), + uri: format!("file:///{name}"), }) .chain(spec.texture_assets.iter().map(|name| FileAsset { kind: AssetKind::Texture, name: name.clone(), + uri: format!("file:///{name}"), })) .collect(), ); 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/analysis/tests/spec/ReferenceTest.ini b/crates/analysis/tests/spec/ReferenceTest.ini index 61ae03c..097230f 100644 --- a/crates/analysis/tests/spec/ReferenceTest.ini +++ b/crates/analysis/tests/spec/ReferenceTest.ini @@ -10,9 +10,39 @@ AudioEvent TestFireSound Volume = 60.0 End +ParticleSystem TestParticleSystem +End + FXList FX_TestFire End +FXList FX_ReferenceNuggets + Sound + Name = $11 + End + Sound + Name = NoSuchFXSound + End + RayEffect + Name = $12 + End + RayEffect + Name = NoSuchRayObject + End + Tracer + TracerName = $13 + End + Tracer + TracerName = NoSuchTracerObject + End + ParticleSystem + Name = $14 + End + ParticleSystem + Name = NoSuchParticleSystem + End +End + CommandButton Command_RefTest ButtonImage = TestButtonImage End diff --git a/crates/analysis/tests/spec/ReferenceTest.spec.toml b/crates/analysis/tests/spec/ReferenceTest.spec.toml index 20c18bd..a4fd03a 100644 --- a/crates/analysis/tests/spec/ReferenceTest.spec.toml +++ b/crates/analysis/tests/spec/ReferenceTest.spec.toml @@ -20,6 +20,27 @@ severity = "warning" code = "unresolved-reference" on = "NoSuchCommandSet" +# FXList nuggets resolve their named assets through the workspace index. +[[diag]] +severity = "warning" +code = "unresolved-reference" +on = "NoSuchFXSound" + +[[diag]] +severity = "warning" +code = "unresolved-reference" +on = "NoSuchRayObject" + +[[diag]] +severity = "warning" +code = "unresolved-reference" +on = "NoSuchTracerObject" + +[[diag]] +severity = "warning" +code = "unresolved-reference" +on = "NoSuchParticleSystem" + # `ButtonImage =` offers the workspace's mapped images. [[complete]] at = "$1" @@ -64,3 +85,19 @@ includes = ["RefTestCommandSet"] [[complete]] at = "$10" includes = ["SET_NORMAL"] + +[[complete]] +at = "$11" +includes = ["TestFireSound"] + +[[complete]] +at = "$12" +includes = ["RefTestObject"] + +[[complete]] +at = "$13" +includes = ["RefTestObject"] + +[[complete]] +at = "$14" +includes = ["TestParticleSystem"] diff --git a/crates/schema/schema.json b/crates/schema/schema.json index 98ae348..29c8c19 100644 --- a/crates/schema/schema.json +++ b/crates/schema/schema.json @@ -5551,7 +5551,8 @@ { "name": "Name", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "audio_event" }, "parse_fn": "INI::parseAsciiString" } @@ -5563,7 +5564,8 @@ { "name": "Name", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "object" }, "parse_fn": "INI::parseAsciiString" }, @@ -5589,7 +5591,8 @@ { "name": "TracerName", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "object" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -5731,7 +5734,8 @@ { "name": "Name", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "particle_system" }, "parse_fn": "INI::parseAsciiString" }, @@ -14868,12 +14872,12 @@ "keyword": "DeliverPayload", "sub_blocks": [ { - "keyword": "DeliveryDecal", - "fields": [ - { - "name": "Texture", - "value_type": { - "kind": "texture_stem" + "keyword": "DeliveryDecal", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString" }, @@ -27154,11 +27158,11 @@ ], "sub_blocks": [ { - "keyword": "DeliveryDecal", - "fields": [ - { - "name": "Texture", - "value_type": { + "keyword": "DeliveryDecal", + "fields": [ + { + "name": "Texture", + "value_type": { "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", @@ -28978,11 +28982,11 @@ ], "sub_blocks": [ { - "keyword": "GridDecalTemplate", - "fields": [ - { - "name": "Texture", - "value_type": { + "keyword": "GridDecalTemplate", + "fields": [ + { + "name": "Texture", + "value_type": { "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", @@ -36802,12 +36806,12 @@ ], "sub_blocks": [ { - "keyword": "DeliveryDecal", - "fields": [ - { - "name": "Texture", - "value_type": { - "kind": "texture_stem" + "keyword": "DeliveryDecal", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -36997,8 +37001,8 @@ "kind": "prefixed", "prefix": "Faction", "value_type": { - "kind": "reference", - "ref_kind": "player_template" + "kind": "enum", + "value_set": "ai_side" } }, { @@ -44160,11 +44164,11 @@ ], "sub_blocks": [ { - "keyword": "AttackAreaDecal", - "fields": [ - { - "name": "Texture", - "value_type": { + "keyword": "AttackAreaDecal", + "fields": [ + { + "name": "Texture", + "value_type": { "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", @@ -44224,11 +44228,11 @@ "doc": "RadiusDecalTemplate nested field table." }, { - "keyword": "TargetingReticleDecal", - "fields": [ - { - "name": "Texture", - "value_type": { + "keyword": "TargetingReticleDecal", + "fields": [ + { + "name": "Texture", + "value_type": { "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", @@ -67256,6 +67260,10 @@ { "name": "GLAToxinGeneral", "value": 11 + }, + { + "name": "Boss", + "value": 12 } ], "doc": "AIData.ini SideInfo / SkirmishBuildList side names." @@ -71468,4 +71476,4 @@ "doc": "Engine-synthesized per-object veterancy upgrade (Upgrade.cpp friend_makeVeterancyUpgrade)." } ] -} +} \ No newline at end of file diff --git a/crates/schema/src/lib.rs b/crates/schema/src/lib.rs index 73a78c7..76686c1 100644 --- a/crates/schema/src/lib.rs +++ b/crates/schema/src/lib.rs @@ -1353,7 +1353,10 @@ mod tests { "parseFactionObjectCreationList", 1, &token_list(vec![ - prefixed("Faction", reference(RefKind::PlayerTemplate)), + // Compares against `PlayerTemplate::getSide()` (e.g. `America`), + // not a PlayerTemplate object name (`FactionAmerica`) — see + // OCLUpdate.cpp. + prefixed("Faction", enum_type("ai_side")), prefixed("OCL", reference(RefKind::ObjectCreationList)), ]), ); diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 0fead88..3c738af 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -12,10 +12,13 @@ path = "src/main.rs" zerosyntax-schema.workspace = true zerosyntax-syntax.workspace = true zerosyntax-analysis.workspace = true +zerosyntax-w3d.workspace = true tower-lsp.workspace = true tokio.workspace = true ropey.workspace = true dashmap.workspace = true +percent-encoding.workspace = true +base64.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index c1e7226..1e33492 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -6,30 +6,88 @@ //! `didChange` deltas are applied to the rope and the document is re-parsed //! once per change batch; read-only requests reuse the cached parse. +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; +use base64::Engine; use dashmap::DashMap; +use percent_encoding::percent_decode_str; use ropey::Rope; use serde::Deserialize; use tower_lsp::lsp_types::*; use tower_lsp::{jsonrpc::Result, Client, LanguageServer}; -use zerosyntax_analysis::diagnostics::DiagnosticsCache; +use zerosyntax_analysis::diagnostics::{DiagnosticsCache, Severity as AnalysisSeverity}; 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}; +use zerosyntax_syntax::{Edit, Parse, Strategy}; use crate::convert::{self, PositionEnc}; -use crate::scan::{collect_scan_paths, load_sibling_str_keys, read_lossy, scan_files}; +use crate::progress::ProgressReporter; +use crate::scan::{ + clear_index_cache, index_cache_path, load_sibling_str_keys, read_asset_uri, read_lossy, + scan_with_cache, ScanOutcome, ScanProgress, ScanStats, +}; #[cfg(test)] use crate::scan::{parse_w3d_models, scan_big, scan_roots}; +const CLEAR_INDEX_CACHE_COMMAND: &str = "zerosyntax.clearIndexCache"; +const REBUILD_INDEX_CACHE_COMMAND: &str = "zerosyntax.rebuildIndexCache"; +const PREVIEW_CACHE_SIZE: usize = 64; + +#[derive(Default)] +struct PreviewCache { + items: HashMap>, + order: VecDeque, + generation: u64, +} + +impl PreviewCache { + fn get(&self, key: &str) -> Option> { + self.items.get(key).cloned() + } + + fn insert(&mut self, generation: u64, key: String, markdown: Arc) { + if self.generation != generation { + return; + } + if let Some(existing) = self.items.get_mut(&key) { + *existing = markdown; + return; + } + // ponytail: FIFO is enough for 64 completion thumbnails; use an LRU + // only if measured model-switching churn makes eviction visible. + if self.items.len() == PREVIEW_CACHE_SIZE { + if let Some(oldest) = self.order.pop_front() { + self.items.remove(&oldest); + } + } + self.order.push_back(key.clone()); + self.items.insert(key, markdown); + } + + fn clear(&mut self) { + self.items.clear(); + self.order.clear(); + self.generation = self.generation.wrapping_add(1); + } +} + +#[derive(Deserialize)] +struct CompletionResolveData { + zerosyntax: String, + model: String, +} + /// An open document: its text (as both a rope for position math and a string /// for the parser) and the parse of that exact text. `did_open`/`did_change` /// are the only places a new parse is produced for an open document; @@ -47,10 +105,144 @@ 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 DEFAULT_PREVIEW_IMAGE_WIDTH: u32 = 160; +const MIN_PREVIEW_IMAGE_WIDTH: u32 = 80; +const MAX_PREVIEW_IMAGE_WIDTH: u32 = 640; +const DEFAULT_PREVIEW_ZOOM_PERCENT: u32 = 100; +const MIN_PREVIEW_ZOOM_PERCENT: u32 = 25; +const MAX_PREVIEW_ZOOM_PERCENT: u32 = 400; const FORMATTING_REGISTRATION_ID: &str = "zerosyntax-formatting"; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum ProgressMode { + Off, + #[default] + Indexing, + Verbose, +} + +impl ProgressMode { + fn from_value(value: Option<&serde_json::Value>) -> Self { + match value.and_then(serde_json::Value::as_str) { + Some("off") => Self::Off, + Some("verbose") => Self::Verbose, + _ => Self::Indexing, + } + } + + fn allows(self, verbose_only: bool) -> bool { + match self { + Self::Off => false, + Self::Indexing => !verbose_only, + Self::Verbose => true, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Off => "off", + Self::Indexing => "indexing", + Self::Verbose => "verbose", + } + } +} + +#[derive(Clone, Copy)] +enum ProgressWork { + Startup, + GameDataReload, + SchemaReload, + ManualRebuild, + DiagnosticsRefresh, +} + +impl ProgressWork { + fn title(self) -> &'static str { + match self { + Self::Startup => "Starting ZeroSyntax", + Self::GameDataReload => "Updating game-data index", + Self::SchemaReload => "Reloading ZeroSyntax schema", + Self::ManualRebuild => "Rebuilding ZeroSyntax index", + Self::DiagnosticsRefresh => "Refreshing ZeroSyntax diagnostics", + } + } + + fn initial_message(self) -> &'static str { + match self { + Self::Startup => "preparing workspace data", + Self::GameDataReload => "applying configured game-data roots", + Self::SchemaReload => "loading the configured schema", + Self::ManualRebuild => "clearing the persistent index cache", + Self::DiagnosticsRefresh => "reanalyzing open documents", + } + } + + fn verbose_only(self) -> bool { + matches!(self, Self::DiagnosticsRefresh) + } +} + +#[derive(Clone, Copy, Debug)] +struct IndexSummary { + ini_total: usize, + model_total: usize, + audio_total: usize, + texture_total: usize, + stats: ScanStats, +} + +impl IndexSummary { + fn completion_message(self, outcome: &str) -> String { + let skipped = self.stats.skipped_inputs(); + if self.stats.discovered_inputs == 0 && skipped == 0 { + return format!("{outcome} — no indexable game data found"); + } + let mut warnings = Vec::new(); + if skipped > 0 { + warnings.push(format!( + "{skipped} input{} skipped", + if skipped == 1 { "" } else { "s" } + )); + } + if !self.stats.cache_written { + warnings.push("persistent cache not saved".into()); + } + let outcome = if warnings.is_empty() { + outcome.to_string() + } else { + format!("{outcome} with warnings") + }; + let warning = (!warnings.is_empty()).then(|| format!("; {}", warnings.join(", "))); + format!( + "{outcome} — {} INI files, {} W3D models, {} audio files, {} textures indexed{}", + self.ini_total, + self.model_total, + self.audio_total, + self.texture_total, + warning.as_deref().unwrap_or_default(), + ) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RuntimeSettings { format_enabled: bool, @@ -60,6 +252,10 @@ struct RuntimeSettings { allow_bare_percentages: bool, map_ordering_diagnostics: bool, debounce_ms: u64, + preview_enabled: bool, + preview_image_width: u32, + preview_zoom_percent: u32, + progress_mode: ProgressMode, } impl Default for RuntimeSettings { @@ -72,6 +268,10 @@ impl Default for RuntimeSettings { allow_bare_percentages: false, map_ordering_diagnostics: true, debounce_ms: DEFAULT_ANALYSIS_DEBOUNCE_MS, + preview_enabled: true, + preview_image_width: DEFAULT_PREVIEW_IMAGE_WIDTH, + preview_zoom_percent: DEFAULT_PREVIEW_ZOOM_PERCENT, + progress_mode: ProgressMode::Indexing, } } } @@ -83,6 +283,8 @@ impl RuntimeSettings { }; let value = value.get("zerosyntax").unwrap_or(value); let analysis = value.get("analysis"); + let preview = value.get("preview"); + let progress = value.get("progress"); let debounce_ms = normalized_debounce_ms(analysis.and_then(|analysis| analysis.get("debounceMs"))); Self { @@ -128,6 +330,25 @@ impl RuntimeSettings { .and_then(|value| value.as_bool()) .unwrap_or(true), debounce_ms, + preview_enabled: preview + .and_then(|preview| preview.get("enable")) + .and_then(|enabled| enabled.as_bool()) + .unwrap_or(true), + preview_image_width: normalized_u32( + preview.and_then(|preview| preview.get("imageWidth")), + DEFAULT_PREVIEW_IMAGE_WIDTH, + MIN_PREVIEW_IMAGE_WIDTH, + MAX_PREVIEW_IMAGE_WIDTH, + ), + preview_zoom_percent: normalized_u32( + preview.and_then(|preview| preview.get("zoomPercent")), + DEFAULT_PREVIEW_ZOOM_PERCENT, + MIN_PREVIEW_ZOOM_PERCENT, + MAX_PREVIEW_ZOOM_PERCENT, + ), + progress_mode: ProgressMode::from_value( + progress.and_then(|progress| progress.get("mode")), + ), } } } @@ -142,6 +363,29 @@ fn normalized_debounce_ms(value: Option<&serde_json::Value>) -> u64 { .unwrap_or(DEFAULT_ANALYSIS_DEBOUNCE_MS) } +fn normalized_u32(value: Option<&serde_json::Value>, default: u32, min: u32, max: u32) -> u32 { + value + .and_then(|value| { + value + .as_i64() + .map(|value| value.clamp(i64::from(min), i64::from(max)) as u32) + .or_else(|| { + value + .as_u64() + .map(|value| value.clamp(min.into(), max.into()) as u32) + }) + }) + .unwrap_or(default) +} + +fn markdown_text(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") + .replace('`', "\\`") +} + pub struct Backend { client: Client, analyzer: Arc>>, @@ -179,11 +423,14 @@ pub struct Backend { /// Whether the client supports `window/workDoneProgress` (the scan /// spinner). Captured at `initialize`. progress_support: OnceLock, + /// Monotonic id source for concurrent work-done progress tokens. + work_progress_id: AtomicU64, /// Delay after the latest edit before whole-document indexes and /// diagnostics refresh. Parsing and definition-name indexing stay eager. analysis_debounce_ms: AtomicU64, /// Monotonic id source for semantic-token results (delta bookkeeping). semantic_result_id: AtomicU64, + preview_cache: Mutex, } fn load_schema(path: &str) -> std::result::Result { @@ -197,10 +444,13 @@ fn load_schema(path: &str) -> std::result::Result { fn load_schema_or_embedded(path: &str) -> (Analyzer, Option) { match load_schema(path) { Ok(analyzer) => (analyzer, None), - Err(error) => ( - Analyzer::embedded(), - Some(format!("ZeroSyntax: {error}; using the built-in schema.")), - ), + Err(error) => { + tracing::debug!(schema_path = path, %error, "custom schema load failed"); + ( + Analyzer::embedded(), + Some(format!("ZeroSyntax: {error}; using the built-in schema.")), + ) + } } } @@ -215,7 +465,7 @@ pub struct VirtualFileParams { /// makes the same file land in the `WorkspaceIndex` under two different keys, /// so every definition appears duplicated. Round-tripping through the file-path /// canonicalises both percent-encoding and drive-letter casing. Non-`file:` -/// schemes are returned unchanged. +/// schemes other than `big:` are returned unchanged. fn canonical_uri(uri: Url) -> Url { if uri.scheme() == "file" { if let Ok(path) = uri.to_file_path() { @@ -223,6 +473,22 @@ fn canonical_uri(uri: Url) -> Url { return canonical; } } + } else if uri.scheme() == "big" { + let Ok(mut path) = percent_decode_str(uri.path()) + .decode_utf8() + .map(|path| path.into_owned()) + else { + return uri; + }; + if path.as_bytes().get(1).is_some_and(u8::is_ascii_lowercase) + && path.as_bytes().get(2) == Some(&b':') + { + let drive = char::from(path.as_bytes()[1].to_ascii_uppercase()).to_string(); + path.replace_range(1..2, &drive); + } + let mut canonical = Url::parse("big:///").expect("static BIG URI is valid"); + canonical.set_path(&path); + return canonical; } uri } @@ -257,6 +523,7 @@ async fn refresh_document( uri: Url, options: RefreshOptions, ) { + let started = Instant::now(); let analyzer = analyzer.read().expect("analyzer lock poisoned").clone(); let Some((rope, parse, version)) = docs.get(&uri).and_then(|d| { if options @@ -268,6 +535,11 @@ async fn refresh_document( Some((d.rope.clone(), d.parse.clone(), d.version)) } }) else { + tracing::trace!( + uri = %uri, + expected_version = ?options.expected_version, + "document refresh skipped because the document closed or changed" + ); return; }; @@ -280,8 +552,17 @@ async fn refresh_document( // Keep this document guard through the short index commit so didChange // cannot advance the document and then be overwritten by this snapshot. - let Some(entry) = docs.get(&uri) else { return }; + let Some(entry) = docs.get(&uri) else { + tracing::trace!(uri = %uri, version, "document refresh superseded before index commit"); + return; + }; if entry.version != version { + tracing::trace!( + uri = %uri, + version, + current_version = entry.version, + "document refresh superseded before index commit" + ); return; } if let Ok(mut idx) = index.write() { @@ -297,15 +578,22 @@ async fn refresh_document( // Take the cache only after the versioned index commit. Expensive work // above never empties the live document's cache when an edit supersedes it. let Some(mut entry) = docs.get_mut(&uri) else { + tracing::trace!(uri = %uri, version, "document refresh superseded before diagnostics"); return; }; if entry.version != version { + tracing::trace!( + uri = %uri, + version, + current_version = entry.version, + "document refresh superseded before diagnostics" + ); return; } let mut cache = std::mem::take(&mut entry.diag_cache); drop(entry); - let lsp_diags: Vec = { + let (lsp_diags, error_count, warning_count, hint_count) = { let idx = index.read().ok(); let mut diags = diagnostics::diagnose_with_cache( &analyzer, @@ -318,24 +606,51 @@ async fn refresh_document( &mut diags, map_ordering_diagnostics.load(Ordering::Relaxed), ); - diags + let (mut errors, mut warnings, mut hints) = (0, 0, 0); + for diagnostic in &diags { + match diagnostic.severity { + AnalysisSeverity::Error => errors += 1, + AnalysisSeverity::Warning => warnings += 1, + AnalysisSeverity::Hint => hints += 1, + } + } + let converted: Vec = diags .iter() .map(|d| convert::to_lsp_diagnostic(&rope, d, options.enc)) - .collect() + .collect(); + (converted, errors, warnings, hints) }; let Some(mut entry) = docs.get_mut(&uri) else { + tracing::trace!(uri = %uri, version, "document refresh superseded before cache commit"); return; }; if entry.version != version { + tracing::trace!( + uri = %uri, + version, + current_version = entry.version, + "document refresh superseded before cache commit" + ); return; } entry.diag_cache = cache; drop(entry); + let diagnostic_count = lsp_diags.len(); client - .publish_diagnostics(uri, lsp_diags, Some(version)) + .publish_diagnostics(uri.clone(), lsp_diags, Some(version)) .await; + tracing::debug!( + uri = %uri, + version, + diagnostic_count, + error_count, + warning_count, + hint_count, + elapsed_ms = started.elapsed().as_millis() as u64, + "document diagnostics published" + ); } impl Backend { @@ -360,8 +675,10 @@ impl Backend { client_base_ini_hint: OnceLock::new(), snippet_support: OnceLock::new(), progress_support: OnceLock::new(), + work_progress_id: AtomicU64::new(1), analysis_debounce_ms: AtomicU64::new(DEFAULT_ANALYSIS_DEBOUNCE_MS), semantic_result_id: AtomicU64::new(1), + preview_cache: Mutex::new(PreviewCache::default()), } } @@ -389,6 +706,24 @@ impl Backend { .fetch_add(1, std::sync::atomic::Ordering::Relaxed) } + async fn begin_progress(&self, work: ProgressWork) -> ProgressReporter { + let mode = self + .settings + .lock() + .map(|settings| settings.progress_mode) + .unwrap_or_default(); + let supported = self.progress_support.get().copied().unwrap_or(false); + let id = self.work_progress_id.fetch_add(1, Ordering::Relaxed); + ProgressReporter::begin( + &self.client, + supported && mode.allows(work.verbose_only()), + NumberOrString::String(format!("zerosyntax/work/{id}")), + work.title(), + work.initial_message(), + ) + .await + } + /// Update the cross-file index from the document's cached parse, run /// diagnostics (via the per-block cache), and publish. The parse itself is /// maintained synchronously by `did_open`/`did_change`. @@ -417,6 +752,12 @@ impl Backend { let enc = self.enc(); let map_ordering_diagnostics = self.map_ordering_diagnostics.clone(); let delay = Duration::from_millis(self.analysis_debounce_ms.load(Ordering::Relaxed)); + tracing::trace!( + uri = %uri, + version, + delay_ms = delay.as_millis() as u64, + "document refresh scheduled" + ); tokio::spawn(async move { tokio::time::sleep(delay).await; refresh_document( @@ -435,15 +776,46 @@ impl Backend { }); } - async fn refresh_all(&self) { + async fn refresh_all_with_progress( + &self, + progress: &ProgressReporter, + start_percentage: u32, + percentage_span: u32, + ) -> usize { let open: Vec = self .docs .iter() .map(|document| document.key().clone()) .collect(); - for uri in open { + let total = open.len(); + if total == 0 { + progress + .report( + "Finalizing workspace state", + Some(start_percentage + percentage_span), + ) + .await; + return 0; + } + progress + .report( + format!("Refreshing diagnostics 0/{total}"), + Some(start_percentage), + ) + .await; + for (done, uri) in open.into_iter().enumerate() { self.refresh(&uri, None).await; + let completed = done + 1; + let percentage = + start_percentage + (completed as u32 * percentage_span / total.max(1) as u32); + progress + .report( + format!("Refreshing diagnostics {completed}/{total}"), + Some(percentage), + ) + .await; } + total } fn clear_diagnostic_caches(&self) { @@ -483,15 +855,39 @@ impl Backend { .await }; if let Err(error) = result { + tracing::error!(%error, enabled, "formatting capability update failed"); self.client .log_message( MessageType::ERROR, - format!("failed to update formatting capability: {error}"), + format!("ZeroSyntax: failed to update formatting capability: {error}"), ) .await; } } + async fn finish_index_work( + &self, + progress: ProgressReporter, + scan: std::result::Result, + success_outcome: &str, + ) -> Option { + match scan { + Ok(summary) => { + self.refresh_all_with_progress(&progress, 92, 8).await; + progress + .end(summary.completion_message(success_outcome)) + .await; + Some(summary) + } + Err(()) => { + progress + .end("Indexing failed — the previous workspace index remains active") + .await; + None + } + } + } + async fn apply_settings(&self, settings: RuntimeSettings) { let _reload = self.reload_lock.lock().await; let previous = { @@ -499,6 +895,7 @@ impl Backend { return; }; if *current == settings { + tracing::trace!("configuration notification made no changes"); return; } let previous = current.clone(); @@ -521,8 +918,79 @@ impl Backend { previous.model_member_strictness != settings.model_member_strictness; let map_ordering_changed = previous.map_ordering_diagnostics != settings.map_ordering_diagnostics; + let preview_changed = previous.preview_enabled != settings.preview_enabled + || previous.preview_image_width != settings.preview_image_width + || previous.preview_zoom_percent != settings.preview_zoom_percent; + let debounce_changed = previous.debounce_ms != settings.debounce_ms; + let format_changed = previous.format_enabled != settings.format_enabled; + let progress_changed = previous.progress_mode != settings.progress_mode; + let mut changed = Vec::new(); + if format_changed { + changed.push("format.enable"); + } + if schema_changed { + changed.push("schemaPath"); + } + if roots_changed { + changed.push("baseIniRoots"); + } + if strictness_changed { + changed.push("analysis.modelMemberStrictness"); + } + if bare_changed { + changed.push("analysis.allowPercentagesWithoutSign"); + } + if map_ordering_changed { + changed.push("analysis.mapOrderingDiagnostics"); + } + if debounce_changed { + changed.push("analysis.debounceMs"); + } + if preview_changed { + changed.push("preview"); + } + if progress_changed { + changed.push("progress.mode"); + } + self.client + .log_message( + MessageType::INFO, + format!( + "ZeroSyntax: settings updated ({}) — formatting={}, schema={}, base roots={}, model strictness={:?}, bare percentages={}, map ordering={}, debounce={} ms, progress={}.", + changed.join(", "), + settings.format_enabled, + if settings.schema_path.is_empty() { "built-in" } else { "custom" }, + settings.base_ini_roots.len(), + settings.model_member_strictness, + settings.allow_bare_percentages, + settings.map_ordering_diagnostics, + settings.debounce_ms, + settings.progress_mode.as_str(), + ), + ) + .await; + tracing::debug!( + schema_path = settings.schema_path, + base_roots = ?settings.base_ini_roots, + "configuration paths updated" + ); + + if preview_changed { + if let Ok(mut cache) = self.preview_cache.lock() { + cache.clear(); + } + } if schema_changed || roots_changed { + let work = if schema_changed { + ProgressWork::SchemaReload + } else { + ProgressWork::GameDataReload + }; + let progress = self.begin_progress(work).await; + if schema_changed { + progress.report("Loading configured schema", Some(0)).await; + } let (mut analyzer, warning) = if schema_changed { if settings.schema_path.is_empty() { (Analyzer::embedded(), None) @@ -532,8 +1000,11 @@ impl Backend { } else if bare_changed { (Analyzer::new(self.analyzer().schema().clone()), None) } else { - self.scan_workspace(self.analyzer(), false).await; - self.refresh_all().await; + let scan = self + .scan_workspace(self.analyzer(), false, "configuration_changed", &progress) + .await; + self.finish_index_work(progress, scan, "Index updated") + .await; return; }; analyzer.set_allow_bare_percentages(settings.allow_bare_percentages); @@ -544,12 +1015,21 @@ impl Backend { } } if let Some(warning) = warning { + self.client + .log_message( + MessageType::WARNING, + "ZeroSyntax: custom schema could not be loaded; using the built-in schema.", + ) + .await; self.client .show_message(MessageType::WARNING, warning) .await; } - self.scan_workspace(analyzer, schema_changed).await; - self.refresh_all().await; + let scan = self + .scan_workspace(analyzer, schema_changed, "configuration_changed", &progress) + .await; + self.finish_index_work(progress, scan, "Index updated") + .await; return; } @@ -567,7 +1047,17 @@ impl Backend { } } if bare_changed || strictness_changed || map_ordering_changed { - self.refresh_all().await; + if self.docs.is_empty() { + return; + } + let progress = self.begin_progress(ProgressWork::DiagnosticsRefresh).await; + let refreshed = self.refresh_all_with_progress(&progress, 0, 100).await; + progress + .end(format!( + "Diagnostics refreshed for {refreshed} open document{}", + if refreshed == 1 { "" } else { "s" } + )) + .await; } } @@ -586,9 +1076,8 @@ impl Backend { .lock() .map(|settings| settings.base_ini_roots.is_empty()) .unwrap_or(true); - if roots_empty && self.client_base_ini_hint.get().copied().unwrap_or(false) { - return; - } + let client_handles_hint = + roots_empty && self.client_base_ini_hint.get().copied().unwrap_or(false); if self .base_roots_hint_shown .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) @@ -596,6 +1085,15 @@ impl Backend { { return; } + self.client + .log_message( + MessageType::WARNING, + "ZeroSyntax: map/solo.ini diagnostics are limited because no base game or mod data is configured.", + ) + .await; + if client_handles_hint { + return; + } self.client .show_message( MessageType::WARNING, @@ -604,15 +1102,17 @@ impl Backend { .await; } - /// Best-effort scan of the workspace roots for `.ini` files to seed the - /// index, so references resolve before a file is opened. The walk + - /// parse runs on a blocking thread (full-mod folders take seconds); the - /// results are applied under the index lock afterwards. Reported to the - /// client as `$/progress` (a status-bar spinner with a `done/total` - /// counter in VS Code) so users can tell "still indexing" apart from - /// "nothing was found". - async fn scan_workspace(&self, analyzer: Arc, replace_analyzer: bool) { - let progress_token = self.begin_scan_progress().await; + /// Best-effort scan of workspace and game-data roots. The blocking scan + /// emits typed phases; the caller owns the progress lifecycle so it can + /// remain visible through the later diagnostics refresh. + async fn scan_workspace( + &self, + analyzer: Arc, + replace_analyzer: bool, + reason: &'static str, + progress: &ProgressReporter, + ) -> std::result::Result { + let started = Instant::now(); let roots = self.roots.lock().map(|r| r.clone()).unwrap_or_default(); let (base_roots, model_member_strictness) = self .settings @@ -624,37 +1124,113 @@ impl Backend { ) }) .unwrap_or_default(); - // The blocking scan streams (done, total) over a channel; forward - // each update as a progress report while waiting for the results. - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>(); + self.client + .log_message( + MessageType::INFO, + format!( + "ZeroSyntax: indexing started (reason={reason}, workspace roots={}, base roots={}).", + roots.len(), + base_roots.len() + ), + ) + .await; + tracing::debug!( + reason, + workspace_roots = ?roots, + base_roots = ?base_roots, + replace_analyzer, + "workspace indexing paths" + ); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let scan_analyzer = analyzer.clone(); let handle = tokio::task::spawn_blocking(move || { - let workspace_paths = collect_scan_paths(&roots); - let base_paths = collect_scan_paths(&base_roots); - let total = workspace_paths.len() + base_paths.len(); - let mut done = 0; - let mut last_percent = u32::MAX; - // Throttle to whole-percent changes (plus the final count) so a - // 10k-file scan sends ~100 notifications, not 10k. - let mut progress = |_done_in_batch: usize, _batch_total: usize| { - done += 1; - let percent = (done * 100 / total.max(1)) as u32; - if percent != last_percent || done == total { - last_percent = percent; - let _ = tx.send((done, total)); + let mut last_percentage = None; + let mut report = |event: ScanProgress| { + if let ScanProgress::Indexing { done, total, .. } = event { + // File checking occupies the first 85% of the complete + // operation. Throttle large scans to percentage changes. + let percentage = (done * 85 / total.max(1)) as u32; + if last_percentage == Some(percentage) && done != total { + return; + } + last_percentage = Some(percentage); } + let _ = tx.send(event); }; - let scanned = scan_files(&scan_analyzer, &workspace_paths, &mut progress); - let base_scanned = scan_files(&scan_analyzer, &base_paths, &mut progress); - (scanned, base_scanned) + scan_with_cache(&scan_analyzer, &roots, &base_roots, &mut report) }); - while let Some((done, total)) = rx.recv().await { - self.report_scan_progress(&progress_token, done, total) - .await; + while let Some(event) = rx.recv().await { + match event { + ScanProgress::Discovering => { + progress + .report("Discovering workspace and game data", None) + .await; + } + ScanProgress::InputsDiscovered { total, skipped } => { + let message = if total == 0 { + if skipped == 0 { + "No indexable game data found".to_string() + } else { + format!("No readable inputs found — {skipped} skipped") + } + } else if skipped == 0 { + format!("Checking 0/{total} inputs") + } else { + format!("Checking 0/{total} inputs — {skipped} skipped during discovery") + }; + progress.report(message, (total == 0).then_some(85)).await; + } + ScanProgress::Indexing { + done, + total, + cache_hits, + cache_misses, + } => { + progress + .report( + format!( + "Checking {done}/{total} inputs — {cache_hits} cached, {cache_misses} reparsed" + ), + Some((done * 85 / total.max(1)) as u32), + ) + .await; + } + ScanProgress::WritingCache => { + progress + .report("Saving the persistent index cache", Some(88)) + .await; + } + } } - let (scanned, base_scanned) = handle.await.unwrap_or_default(); - + let ScanOutcome { + entries: scanned, + stats, + } = match handle.await { + Ok(outcome) => outcome, + Err(error) => { + tracing::error!(reason, %error, "workspace indexing worker failed"); + self.client + .log_message( + MessageType::ERROR, + format!( + "ZeroSyntax: indexing worker failed (reason={reason}); the previous index remains active." + ), + ) + .await; + return Err(()); + } + }; if replace_analyzer { + let open_count = self.docs.len(); + progress + .report( + format!( + "Reparsing {open_count} open document{} with the new schema", + if open_count == 1 { "" } else { "s" } + ), + Some(89), + ) + .await; if let Ok(mut current) = self.analyzer.write() { *current = analyzer.clone(); } @@ -665,33 +1241,35 @@ impl Backend { } } - let base_ini_count = base_scanned + let base_ini_count = scanned .iter() - .filter(|(_, _, _, _, _, _, models, assets, _)| models.is_empty() && assets.is_empty()) + .filter(|(is_base, (_, _, _, _, _, _, models, assets, _))| { + *is_base && models.is_empty() && assets.is_empty() + }) .count(); self.base_indexed_count .store(base_ini_count, Ordering::Relaxed); self.scan_finished.store(true, Ordering::Relaxed); - let ini_total = base_ini_count - + scanned - .iter() - .filter(|(_, _, _, _, _, _, models, assets, _)| { - models.is_empty() && assets.is_empty() - }) - .count(); - let model_total: usize = base_scanned + let ini_total = scanned + .iter() + .filter(|(_, (_, _, _, _, _, _, models, assets, _))| { + models.is_empty() && assets.is_empty() + }) + .count(); + let model_total: usize = scanned .iter() - .chain(scanned.iter()) - .map(|(_, _, _, _, _, _, models, _, _)| models.len()) + .map(|(_, (_, _, _, _, _, _, models, _, _))| models.len()) .sum(); - let (audio_total, texture_total) = base_scanned + let (audio_total, texture_total) = scanned .iter() - .chain(scanned.iter()) - .flat_map(|(_, _, _, _, _, _, _, assets, _)| assets) + .flat_map(|(_, (_, _, _, _, _, _, _, assets, _))| assets) .fold((0, 0), |(audio, texture), asset| match asset.kind { zerosyntax_analysis::index::AssetKind::Audio => (audio + 1, texture), zerosyntax_analysis::index::AssetKind::Texture => (audio, texture + 1), }); + progress + .report("Activating workspace index", Some(90)) + .await; // Build the replacement off to the side so removed roots cannot leave // stale definitions, assets, inheritance, models, or virtual files. let open: std::collections::HashSet = self @@ -702,8 +1280,8 @@ impl Backend { let mut replacement = WorkspaceIndex::new(); replacement.set_model_member_strictness(model_member_strictness); self.virtual_files.clear(); - for (uri, defs, refs, tags, object_models, object_parents, models, assets, text) in - base_scanned.into_iter().chain(scanned) + for (_, (uri, defs, refs, tags, object_models, object_parents, models, assets, text)) in + scanned { if let Some(text) = text { self.virtual_files.insert(uri.clone(), text); @@ -714,7 +1292,7 @@ impl Backend { replacement.set_file_tags(&uri, tags); replacement.set_file_object_models(&uri, object_models); replacement.set_file_object_parents(&uri, object_parents); - replacement.set_file_models(&uri, models); + replacement.insert_file_models_prepared(&uri, models); replacement.set_file_assets(&uri, assets); } } @@ -734,88 +1312,48 @@ impl Backend { if let Ok(mut index) = self.index.write() { *index = replacement; } + if let Ok(mut cache) = self.preview_cache.lock() { + cache.clear(); + } self.clear_diagnostic_caches(); - self.end_scan_progress( - progress_token, + let skipped_inputs = stats.skipped_inputs(); + if skipped_inputs > 0 { + self.client + .log_message( + MessageType::WARNING, + format!( + "ZeroSyntax: indexing completed with warnings (reason={reason}) — {skipped_inputs} input{} could not be indexed.", + if skipped_inputs == 1 { "" } else { "s" } + ), + ) + .await; + } + if (stats.discovered_inputs > 0 || skipped_inputs > 0) && !stats.cache_written { + self.client + .log_message( + MessageType::WARNING, + "ZeroSyntax: the index cache could not be saved; unchanged files may be reparsed next time.", + ) + .await; + } + self.client + .log_message( + MessageType::INFO, + format!( + "ZeroSyntax: indexing completed (reason={reason}) — {ini_total} INI files, {model_total} W3D models, {audio_total} audio files, {texture_total} textures; {} cached, {} reparsed, {skipped_inputs} skipped in {} ms.", + stats.cache_hits, + stats.cache_misses, + started.elapsed().as_millis() + ), + ) + .await; + Ok(IndexSummary { ini_total, model_total, audio_total, texture_total, - ) - .await; - } - - /// Ask the client to show an indexing spinner. Returns the token to end - /// it with, or `None` when the client doesn't support work-done progress. - async fn begin_scan_progress(&self) -> Option { - if !self.progress_support.get().copied().unwrap_or(false) { - return None; - } - let token = NumberOrString::String("zerosyntax/indexing".into()); - self.client - .send_request::(WorkDoneProgressCreateParams { - token: token.clone(), - }) - .await - .ok()?; - self.client - .send_notification::(ProgressParams { - token: token.clone(), - value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin( - WorkDoneProgressBegin { - title: "Indexing game data".into(), - message: Some("scanning workspace and configured game-data roots".into()), - cancellable: Some(false), - // Signals that reports will carry a percentage. - percentage: Some(0), - }, - )), - }) - .await; - Some(token) - } - - /// Forward one `done/total` update to the client's progress UI. - async fn report_scan_progress( - &self, - token: &Option, - done: usize, - total: usize, - ) { - let Some(token) = token else { return }; - self.client - .send_notification::(ProgressParams { - token: token.clone(), - value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report( - WorkDoneProgressReport { - message: Some(format!("{done}/{total} files")), - percentage: Some((done * 100 / total.max(1)) as u32), - cancellable: Some(false), - }, - )), - }) - .await; - } - - async fn end_scan_progress( - &self, - token: Option, - ini_total: usize, - model_total: usize, - audio_total: usize, - texture_total: usize, - ) { - let Some(token) = token else { return }; - self.client - .send_notification::(ProgressParams { - token, - value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd { - message: Some(format!( - "{ini_total} INI files, {model_total} W3D models, {audio_total} audio files, {texture_total} textures indexed" - )), - })), - }) - .await; + stats, + }) } /// The cached state for an open document (rope + parse), if any. @@ -828,7 +1366,8 @@ impl Backend { /// Resolve a URI's text to a rope, preferring open documents and falling /// back to disk (for go-to-definition into unopened files). fn rope_for(&self, uri: &Url) -> Option { - if let Some(doc) = self.docs.get(uri) { + let uri = canonical_uri(uri.clone()); + if let Some(doc) = self.docs.get(&uri) { return Some(doc.rope.clone()); } if uri.scheme() == "big" { @@ -842,20 +1381,53 @@ impl Backend { } pub async fn read_virtual_file(&self, params: VirtualFileParams) -> Result> { + let Some(uri) = Url::parse(¶ms.uri).ok().map(canonical_uri) else { + return Ok(None); + }; Ok(self .virtual_files - .get(¶ms.uri) + .get(uri.as_str()) .map(|text| text.to_string())) } + pub async fn index_cache_path(&self) -> Result { + 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()) + } + /// 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 @@ -905,6 +1477,8 @@ impl LanguageServer for Backend { // Editor-facing settings arrive as `initializationOptions`. Shape: // `{ "format": {"enable": bool}, "schemaPath": "schema.json", + // "preview": {"enable": true, "imageWidth": 160, "zoomPercent": 100}, + // "progress": {"mode": "indexing"}, // "analysis": {"modelMemberStrictness": "compatible", // "allowPercentagesWithoutSign": false, // "mapOrderingDiagnostics": true, "debounceMs": 250}, @@ -987,6 +1561,7 @@ impl LanguageServer for Backend { )), completion_provider: Some(CompletionOptions { trigger_characters: Some(vec!["=".into(), " ".into()]), + resolve_provider: Some(true), ..Default::default() }), semantic_tokens_provider: Some( @@ -1019,23 +1594,70 @@ impl LanguageServer for Backend { ..Default::default() }, )), + execute_command_provider: Some(ExecuteCommandOptions { + commands: vec![ + CLEAR_INDEX_CACHE_COMMAND.into(), + REBUILD_INDEX_CACHE_COMMAND.into(), + ], + work_done_progress_options: Default::default(), + }), ..Default::default() }, }) } async fn initialized(&self, _: InitializedParams) { + let roots = self + .roots + .lock() + .map(|roots| roots.clone()) + .unwrap_or_default(); + let settings = self + .settings + .lock() + .map(|settings| settings.clone()) + .unwrap_or_default(); + self.client + .log_message( + MessageType::INFO, + format!( + "ZeroSyntax: initializing v{} (encoding={:?}, workspace roots={}, base roots={}, schema={}, work progress={} {}, snippets={}, dynamic formatting={}).", + env!("CARGO_PKG_VERSION"), + self.enc(), + roots.len(), + settings.base_ini_roots.len(), + if settings.schema_path.is_empty() { "built-in" } else { "custom" }, + self.progress_support.get().copied().unwrap_or(false), + settings.progress_mode.as_str(), + self.snippet_support.get().copied().unwrap_or(false), + self.formatting_dynamic_registration.get().copied().unwrap_or(false), + ), + ) + .await; + tracing::debug!( + workspace_roots = ?roots, + base_roots = ?settings.base_ini_roots, + schema_path = settings.schema_path, + "server initialization paths" + ); if let Some(error) = self.schema_error.lock().ok().and_then(|mut e| e.take()) { + self.client + .log_message( + MessageType::WARNING, + "ZeroSyntax: custom schema could not be loaded; using the built-in schema.", + ) + .await; self.client.show_message(MessageType::WARNING, error).await; } if self.format_enabled() { self.set_formatting_enabled(true).await; } - self.scan_workspace(self.analyzer(), false).await; - // Re-publish diagnostics for any already-open docs now that the index - // is populated (so cross-file references resolve). The cached parse is - // still valid — only the index changed. - self.refresh_all().await; + let progress = self.begin_progress(ProgressWork::Startup).await; + let scan = self + .scan_workspace(self.analyzer(), false, "startup", &progress) + .await; + // Keep progress alive until cross-file diagnostics reflect the index. + self.finish_index_work(progress, scan, "Ready").await; let (ini, models, audio, textures) = { let idx = self.index.read().ok(); let models = idx @@ -1067,13 +1689,109 @@ impl LanguageServer for Backend { .log_message( MessageType::INFO, format!( - "zerosyntax language server ready ({ini} base INI files, {models} W3D models, {audio} audio files, {textures} textures indexed)" + "ZeroSyntax: language server ready ({ini} base INI files, {models} W3D models, {audio} audio files, {textures} textures indexed)." ), ) .await; } + async fn execute_command( + &self, + params: ExecuteCommandParams, + ) -> Result> { + if params.command != CLEAR_INDEX_CACHE_COMMAND + && params.command != REBUILD_INDEX_CACHE_COMMAND + { + 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 { + None + }; + if let Some(progress) = &progress { + progress + .report("Clearing the persistent index cache", Some(0)) + .await; + } + let cleared = match clear_index_cache(&roots, &base_roots) { + Ok(cleared) => cleared, + Err(error) => { + tracing::error!(%error, "asset index cache clear failed"); + if let Some(progress) = progress.take() { + progress + .end("Index rebuild failed — the cache could not be cleared") + .await; + } + self.client + .log_message( + MessageType::ERROR, + "ZeroSyntax: failed to clear the index cache.", + ) + .await; + return Err(tower_lsp::jsonrpc::Error::internal_error()); + } + }; + if params.command == REBUILD_INDEX_CACHE_COMMAND { + let previous_scan_finished = self.scan_finished.load(Ordering::Relaxed); + let previous_base_indexed_count = self.base_indexed_count.load(Ordering::Relaxed); + self.scan_finished.store(false, Ordering::Relaxed); + self.base_indexed_count.store(0, Ordering::Relaxed); + let progress = progress.expect("rebuild progress initialized above"); + let scan = self + .scan_workspace(self.analyzer(), false, "manual_cache_rebuild", &progress) + .await; + if self + .finish_index_work(progress, scan, "Index rebuilt") + .await + .is_none() + { + self.scan_finished + .store(previous_scan_finished, Ordering::Relaxed); + self.base_indexed_count + .store(previous_base_indexed_count, Ordering::Relaxed); + return Err(tower_lsp::jsonrpc::Error::internal_error()); + } + self.client + .log_message( + MessageType::INFO, + format!("ZeroSyntax: index cache rebuilt (previous cache cleared={cleared})."), + ) + .await; + self.client + .show_message(MessageType::INFO, "ZeroSyntax index cache rebuilt.") + .await; + return Ok(Some( + serde_json::json!({ "rebuilt": true, "cleared": cleared }), + )); + } + let message = if cleared { + "ZeroSyntax index cache cleared. Restart the language server to rebuild it." + } else { + "ZeroSyntax index cache is already clear." + }; + self.client.log_message(MessageType::INFO, message).await; + self.client.show_message(MessageType::INFO, message).await; + Ok(Some(serde_json::json!({ "cleared": cleared }))) + } + async fn shutdown(&self) -> Result<()> { + self.client + .log_message( + MessageType::INFO, + "ZeroSyntax: language server shutting down.", + ) + .await; Ok(()) } @@ -1088,6 +1806,12 @@ impl LanguageServer for Backend { let rope = Rope::from_str(&text); let parse = Arc::new(self.analyzer().parse(&text)); let version = params.text_document.version; + tracing::debug!( + uri = %uri, + version, + text_bytes = text.len(), + "document opened" + ); self.docs.insert( uri.clone(), DocumentState { @@ -1107,8 +1831,24 @@ impl LanguageServer for Backend { let version = params.text_document.version; let enc = self.enc(); let analyzer = self.analyzer(); + let change_count = params.content_changes.len(); + let incoming_bytes = params + .content_changes + .iter() + .map(|change| change.text.len()) + .sum::(); + let all_ranged = params + .content_changes + .iter() + .all(|change| change.range.is_some()); + let mut spliced_count = 0; + let mut full_fallback_count = 0; + let mut full_replacement = false; + let parse_strategy; + let text_bytes; { let Some(mut entry) = self.docs.get_mut(&uri) else { + tracing::trace!(uri = %uri, version, "document change ignored because it is not open"); return; }; let entry = entry.value_mut(); @@ -1122,21 +1862,16 @@ impl LanguageServer for Backend { // format edits can each carry kilobytes). const BULK_CHANGE_THRESHOLD: usize = 8; const BULK_TEXT_BYTES: usize = 32 * 1024; - let bulk = params.content_changes.len() > BULK_CHANGE_THRESHOLD - || (params.content_changes.len() > 1 - && params - .content_changes - .iter() - .map(|c| c.text.len()) - .sum::() - > BULK_TEXT_BYTES); - if bulk && params.content_changes.iter().all(|c| c.range.is_some()) { + let bulk = change_count > BULK_CHANGE_THRESHOLD + || (change_count > 1 && incoming_bytes > BULK_TEXT_BYTES); + if bulk && all_ranged { for change in params.content_changes { convert::apply_change(&mut entry.rope, change.range, &change.text, enc); } entry.text = entry.rope.to_string().into(); entry.parse = Arc::new(analyzer.parse(&entry.text)); entry.version = version; + parse_strategy = "bulk_full_parse"; } else { // Each change applies to the text produced by the previous // one. The parse is kept in lockstep via incremental reparse, @@ -1154,8 +1889,12 @@ impl LanguageServer for Backend { old_end: old_end as usize, new_len: change.text.len(), }; - let (parse, _strategy) = + let (parse, strategy) = analyzer.reparse(&entry.parse, &entry.text, &new_text, edit); + match strategy { + Strategy::Spliced => spliced_count += 1, + Strategy::Full => full_fallback_count += 1, + } entry.parse = Arc::new(parse); entry.text = new_text; } @@ -1164,11 +1903,20 @@ impl LanguageServer for Backend { entry.rope = Rope::from_str(&change.text); entry.text = change.text.into(); entry.parse = Arc::new(analyzer.parse(&entry.text)); + full_replacement = true; } } } entry.version = version; + parse_strategy = if full_replacement { + "full_document_replacement" + } else if full_fallback_count > 0 { + "incremental_full_fallback" + } else { + "incremental_splice" + }; } + text_bytes = entry.text.len(); // Definition names power reference completions and are cheap to // extract. Commit them while the document guard preserves version // order; the expensive index passes wait for the debounce. @@ -1177,13 +1925,26 @@ impl LanguageServer for Backend { idx.set_file(uri.as_str(), defs); } } + tracing::debug!( + uri = %uri, + version, + change_count, + incoming_bytes, + text_bytes, + parse_strategy, + spliced_count, + full_fallback_count, + "document changed" + ); self.schedule_refresh(uri, version); } async fn did_close(&self, params: DidCloseTextDocumentParams) { // Keep the file's symbols in the index (it still exists on disk); just // drop the in-memory buffer. - self.docs.remove(&canonical_uri(params.text_document.uri)); + let uri = canonical_uri(params.text_document.uri); + let removed = self.docs.remove(&uri).is_some(); + tracing::debug!(uri = %uri, removed, "document closed"); } async fn completion(&self, params: CompletionParams) -> Result> { @@ -1208,6 +1969,131 @@ impl LanguageServer for Backend { Ok(Some(CompletionResponse::Array(items))) } + async fn completion_resolve(&self, mut item: CompletionItem) -> Result { + let Some(data) = item + .data + .clone() + .and_then(|value| serde_json::from_value::(value).ok()) + .filter(|data| { + data.zerosyntax == "w3d-model-preview" + && !data.model.is_empty() + && data.model.len() <= 256 + }) + else { + return Ok(item); + }; + let source = self + .index + .read() + .ok() + .and_then(|index| index.effective_model_source(&data.model).map(str::to_owned)); + let Some(source) = source else { + return Ok(item); + }; + let (preview_enabled, image_width, zoom_percent) = self + .settings + .lock() + .map(|settings| { + ( + settings.preview_enabled, + settings.preview_image_width, + settings.preview_zoom_percent, + ) + }) + .unwrap_or(( + true, + DEFAULT_PREVIEW_IMAGE_WIDTH, + DEFAULT_PREVIEW_ZOOM_PERCENT, + )); + if !preview_enabled { + item.documentation = None; + return Ok(item); + } + let cache_key = format!( + "{}\0{}\0{image_width}\0{zoom_percent}", + data.model.to_ascii_lowercase(), + source + ); + let (cached, cache_generation) = self + .preview_cache + .lock() + .map(|cache| (cache.get(&cache_key), cache.generation)) + .unwrap_or((None, 0)); + if let Some(markdown) = cached { + item.documentation = Some(Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: markdown.to_string(), + })); + return Ok(item); + } + + let index = self.index.clone(); + let model = data.model.clone(); + let zoom = zoom_percent as f32 / 100.0; + let rendered = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + let bytes = read_asset_uri(&source)?; + let file = zerosyntax_w3d::W3dFile::parse(&bytes)?; + file.render_thumbnail(&model, zoom, |texture| { + let uri = index.read().ok().and_then(|index| { + index + .effective_texture_source(texture) + .map(|asset| asset.uri.clone()) + })?; + read_asset_uri(&uri).ok() + }) + .map_err(anyhow::Error::from) + }) + .await; + + let markdown = match rendered { + Ok(Ok(rendered)) => { + let encoded = + base64::engine::general_purpose::STANDARD.encode(rendered.png.as_slice()); + let label = markdown_text(&data.model); + let mut markdown = format!( + "![{label} model preview](data:image/png;base64,{encoded}|width={image_width})\n\n`{label}`" + ); + if !rendered.missing_textures.is_empty() { + let shown = rendered + .missing_textures + .iter() + .take(3) + .map(|name| format!("`{}`", markdown_text(name))) + .collect::>() + .join(", "); + let remaining = rendered.missing_textures.len().saturating_sub(3); + markdown.push_str("\n\nMissing texture"); + if rendered.missing_textures.len() != 1 { + markdown.push('s'); + } + markdown.push_str(&format!(": {shown}")); + if remaining > 0 { + markdown.push_str(&format!(" and {remaining} more")); + } + } + Arc::::from(markdown) + } + Ok(Err(error)) => { + tracing::debug!(%error, "W3D completion preview render details"); + tracing::warn!("could not render W3D completion preview"); + Arc::::from("_Preview unavailable: unsupported or malformed W3D data._") + } + Err(error) => { + tracing::debug!(%error, "W3D completion preview task details"); + tracing::warn!("W3D preview task failed"); + Arc::::from("_Preview unavailable: unsupported or malformed W3D data._") + } + }; + if let Ok(mut cache) = self.preview_cache.lock() { + cache.insert(cache_generation, cache_key, markdown.clone()); + } + item.documentation = Some(Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: markdown.to_string(), + })); + Ok(item) + } + async fn semantic_tokens_full( &self, params: SemanticTokensParams, @@ -1410,18 +2296,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(); @@ -1530,19 +2426,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(); @@ -1563,7 +2488,7 @@ impl LanguageServer for Backend { }; Ok(Some(PrepareRenameResponse::Range(convert::span_to_range( &rope, - sym.span, + sym.span(), self.enc(), )))) } @@ -1585,11 +2510,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(); @@ -1812,12 +2757,99 @@ mod tests { assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(12.5))), 250); } + #[test] + fn preview_settings_default_and_clamp() { + let defaults = RuntimeSettings::default(); + assert!(defaults.preview_enabled); + assert_eq!(defaults.preview_image_width, 160); + assert_eq!(defaults.preview_zoom_percent, 100); + + let settings = RuntimeSettings::from_value(Some(&serde_json::json!({ + "preview": {"enable": false, "imageWidth": 10_000, "zoomPercent": 0} + }))); + assert!(!settings.preview_enabled); + assert_eq!(settings.preview_image_width, 640); + assert_eq!(settings.preview_zoom_percent, 25); + } + + #[test] + fn progress_mode_defaults_and_parses() { + assert_eq!( + RuntimeSettings::default().progress_mode, + ProgressMode::Indexing + ); + assert_eq!( + RuntimeSettings::from_value(Some(&serde_json::json!({ + "progress": {"mode": "off"} + }))) + .progress_mode, + ProgressMode::Off + ); + assert_eq!( + RuntimeSettings::from_value(Some(&serde_json::json!({ + "zerosyntax": {"progress": {"mode": "verbose"}} + }))) + .progress_mode, + ProgressMode::Verbose + ); + assert_eq!( + RuntimeSettings::from_value(Some(&serde_json::json!({ + "progress": {"mode": "future-value"} + }))) + .progress_mode, + ProgressMode::Indexing + ); + assert!(!ProgressMode::Off.allows(false)); + assert!(ProgressMode::Indexing.allows(false)); + assert!(!ProgressMode::Indexing.allows(true)); + assert!(ProgressMode::Verbose.allows(true)); + } + + #[test] + fn index_completion_distinguishes_empty_success_and_warnings() { + let summary = |stats| IndexSummary { + ini_total: 2, + model_total: 1, + audio_total: 0, + texture_total: 0, + stats, + }; + assert_eq!( + summary(ScanStats::default()).completion_message("Ready"), + "Ready — no indexable game data found" + ); + assert!(summary(ScanStats { + discovered_inputs: 2, + cache_written: true, + ..ScanStats::default() + }) + .completion_message("Ready") + .starts_with("Ready — 2 INI files")); + assert!(summary(ScanStats { + discovered_inputs: 2, + scan_failures: 1, + cache_written: false, + ..ScanStats::default() + }) + .completion_message("Ready") + .contains("Ready with warnings")); + assert!(summary(ScanStats { + discovery_failures: 1, + cache_written: true, + ..ScanStats::default() + }) + .completion_message("Ready") + .contains("1 input skipped")); + } + #[test] fn runtime_settings_accept_startup_and_vscode_shapes() { let startup = RuntimeSettings::from_value(Some(&serde_json::json!({ "format": {"enable": true}, "schemaPath": "schema.json", "baseIniRoots": ["base"], + "progress": {"mode": "verbose"}, + "preview": {"enable": false, "imageWidth": 320, "zoomPercent": 150}, "analysis": {"modelMemberStrictness": "strict", "debounceMs": 9000} }))); let notification = RuntimeSettings::from_value(Some(&serde_json::json!({ @@ -1825,11 +2857,17 @@ mod tests { "format": {"enable": true}, "schema": {"path": "schema.json"}, "baseIniRoots": ["base"], + "progress": {"mode": "verbose"}, + "preview": {"enable": false, "imageWidth": 320, "zoomPercent": 150}, "analysis": {"modelMemberStrictness": "strict", "debounceMs": 9000} } }))); assert_eq!(startup, notification); assert_eq!(startup.debounce_ms, 5000); + assert!(!startup.preview_enabled); + assert_eq!(startup.preview_image_width, 320); + assert_eq!(startup.preview_zoom_percent, 150); + assert_eq!(startup.progress_mode, ProgressMode::Verbose); } #[test] @@ -1865,11 +2903,45 @@ mod tests { } #[test] - fn canonical_uri_pass_through_non_file() { + fn canonical_uri_pass_through_other_schemes() { let u = Url::parse("untitled:///buffer").unwrap(); assert_eq!(canonical_uri(u.clone()), u); } + #[test] + fn canonical_uri_normalises_big_uri_path() { + let scanner = + Url::parse("big:///C:/Game%20Folder/Base%23.big!/Data/INI/Object.ini").unwrap(); + for client in [ + "big:///C%3A/Game%20Folder/Base%23.big!/Data/INI/Object.ini", + "big:/c%3A/Game%20Folder/Base%23.big%21/Data/INI/Object.ini", + ] { + assert_eq!(canonical_uri(Url::parse(client).unwrap()), scanner); + } + } + + #[test] + fn canonical_uri_keeps_scanner_big_uri() { + let scanner = + Url::parse("big:///C:/Game%20Folder/Base%23.big!/Data/INI/Object.ini").unwrap(); + assert_eq!(canonical_uri(scanner.clone()), scanner); + } + + #[test] + fn malformed_or_unknown_big_uri_has_no_virtual_content() { + let files = DashMap::new(); + files.insert( + "big:///C:/Game%20Folder/Base.big!/Data/INI/Object.ini".to_string(), + Arc::::from("Object Known\nEnd\n"), + ); + let malformed = Url::parse("big:///C%3A/Game%FF/Base.big!/Data/INI/Object.ini").unwrap(); + let unknown = + Url::parse("big:///C%3A/Game%20Folder/Base.big!/Data/INI/Unknown.ini").unwrap(); + assert_eq!(canonical_uri(malformed.clone()), malformed); + assert!(!files.contains_key(canonical_uri(malformed).as_str())); + assert!(!files.contains_key(canonical_uri(unknown).as_str())); + } + #[test] fn detects_map_layer_filenames() { assert!(is_map_layer_file("file:///C:/Maps/Foo/map.ini")); @@ -1880,7 +2952,7 @@ mod tests { #[test] fn scans_ini_w3d_audio_and_texture_from_big_archive() { let dir = std::env::temp_dir(); - let path = dir.join(format!("zerosyntax-test-{}.big", std::process::id())); + let path = dir.join(format!("zerosyntax test #-{}.big", std::process::id())); let entries: Vec<(&str, &[u8])> = vec![ ("Data\\INI\\Test.ini", b"Object BigArchiveObject\nEnd\n"), ("Art\\Good.w3d", b""), @@ -1914,7 +2986,6 @@ mod tests { let analyzer = Analyzer::embedded(); let scanned = scan_big(&analyzer, &path).unwrap(); - let _ = std::fs::remove_file(&path); assert_eq!(scanned.len(), 3, "INI, W3D, and one aggregated asset entry"); let ini = scanned.iter().find(|entry| !entry.1.is_empty()).unwrap(); @@ -1928,6 +2999,12 @@ mod tests { assert_eq!(assets.len(), 2); assert!(assets.iter().any(|asset| asset.name == "Click.WAV")); assert!(assets.iter().any(|asset| asset.name == "Particle.DDS")); + let texture = assets + .iter() + .find(|asset| asset.name == "Particle.DDS") + .unwrap(); + assert_eq!(read_asset_uri(&texture.uri).unwrap(), b"not read"); + let _ = std::fs::remove_file(&path); } #[test] @@ -1945,6 +3022,7 @@ mod tests { assert_eq!(assets.len(), 2); assert!(assets.iter().any(|asset| asset.name == "Click.wav")); assert!(assets.iter().any(|asset| asset.name == "Particle.tga")); + assert!(assets.iter().all(|asset| asset.uri.starts_with("file:"))); } #[test] @@ -2051,6 +3129,27 @@ End assert!(good.members.iter().any(|m| m == "Cargo01"), "{good:?}"); } + #[test] + fn malformed_w3d_keeps_filename_fallback_model() { + let mut truncated = 0u32.to_le_bytes().to_vec(); + truncated.extend_from_slice(&16u32.to_le_bytes()); + + let models = parse_w3d_models(&truncated, "Fallback"); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "Fallback"); + assert!(models[0].members.is_empty()); + } + + #[test] + fn invalidated_preview_is_not_cached_again() { + let mut cache = PreviewCache::default(); + let generation = cache.generation; + cache.clear(); + cache.insert(generation, "model".into(), Arc::from("stale")); + + assert!(cache.get("model").is_none()); + } + #[test] fn parses_w3d_aggregate_and_emitter_names() { fn fixed(name: &str) -> [u8; N] { diff --git a/crates/server/src/convert.rs b/crates/server/src/convert.rs index 338dd41..b3cd75d 100644 --- a/crates/server/src/convert.rs +++ b/crates/server/src/convert.rs @@ -171,6 +171,12 @@ pub fn to_lsp_completion(c: Completion, snippets_supported: bool) -> CompletionI (None, None, None) }; + let data = (c.kind == CompletionKind::W3dModel).then(|| { + serde_json::json!({ + "zerosyntax": "w3d-model-preview", + "model": c.label, + }) + }); CompletionItem { label: c.label, kind: Some(match c.kind { @@ -180,8 +186,10 @@ pub fn to_lsp_completion(c: Completion, snippets_supported: bool) -> CompletionI CompletionKind::EnumMember => CompletionItemKind::ENUM_MEMBER, CompletionKind::Value => CompletionItemKind::VALUE, CompletionKind::Reference => CompletionItemKind::REFERENCE, + CompletionKind::W3dModel => CompletionItemKind::REFERENCE, }), detail: c.detail, + data, insert_text, insert_text_format, command, @@ -324,6 +332,7 @@ pub fn semantic_tokens_splice( #[cfg(test)] mod tests { use super::*; + use zerosyntax_analysis::completion::{Completion, CompletionKind}; use zerosyntax_analysis::semantic::{SemKind, SemToken}; /// Apply a splice the way a client would, for the equivalence test below. @@ -338,6 +347,24 @@ mod tests { out } + #[test] + fn model_completion_is_tagged_but_not_eagerly_rendered() { + let item = to_lsp_completion( + Completion { + label: "AVTank".into(), + kind: CompletionKind::W3dModel, + detail: Some("W3D model".into()), + insert: None, + }, + false, + ); + assert_eq!( + item.data.as_ref().and_then(|data| data.get("model")), + Some(&serde_json::json!("AVTank")) + ); + assert!(item.documentation.is_none()); + } + #[test] fn splice_reproduces_next_from_prev() { let tok = |dl, ds, len, ty| SemanticToken { diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index ffafb35..d9b662b 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -4,6 +4,7 @@ mod backend; mod cli; mod convert; +mod progress; mod scan; use backend::Backend; @@ -19,9 +20,10 @@ async fn main() -> std::process::ExitCode { // Log to stderr (stdout is reserved for the LSP wire protocol). tracing_subscriber::fmt() .with_writer(std::io::stderr) + .with_ansi(false) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), ) .init(); @@ -29,6 +31,7 @@ async fn main() -> std::process::ExitCode { let stdout = tokio::io::stdout(); let (service, socket) = LspService::build(Backend::new) .custom_method("zerosyntax/readVirtualFile", Backend::read_virtual_file) + .custom_method("zerosyntax/indexCachePath", Backend::index_cache_path) .finish(); Server::new(stdin, stdout, socket).serve(service).await; std::process::ExitCode::SUCCESS diff --git a/crates/server/src/progress.rs b/crates/server/src/progress.rs new file mode 100644 index 0000000..027322a --- /dev/null +++ b/crates/server/src/progress.rs @@ -0,0 +1,88 @@ +//! LSP work-done progress reporting. +//! +//! Callers own the user-visible operation lifecycle; this module hides the +//! protocol handshake and notification details. A disabled reporter is a +//! no-op, so callers can report phases without branching on client support or +//! runtime settings. + +use tower_lsp::lsp_types::{ + notification, request, NumberOrString, ProgressParams, ProgressParamsValue, WorkDoneProgress, + WorkDoneProgressBegin, WorkDoneProgressCreateParams, WorkDoneProgressEnd, + WorkDoneProgressReport, +}; +use tower_lsp::Client; + +pub(crate) struct ProgressReporter { + client: Client, + token: Option, +} + +impl ProgressReporter { + pub(crate) async fn begin( + client: &Client, + enabled: bool, + token: NumberOrString, + title: &str, + message: &str, + ) -> Self { + let mut reporter = Self { + client: client.clone(), + token: None, + }; + if !enabled { + return reporter; + } + if client + .send_request::(WorkDoneProgressCreateParams { + token: token.clone(), + }) + .await + .is_err() + { + return reporter; + } + client + .send_notification::(ProgressParams { + token: token.clone(), + value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin( + WorkDoneProgressBegin { + title: title.into(), + message: Some(message.into()), + cancellable: Some(false), + percentage: None, + }, + )), + }) + .await; + reporter.token = Some(token); + reporter + } + + pub(crate) async fn report(&self, message: impl Into, percentage: Option) { + let Some(token) = &self.token else { return }; + self.client + .send_notification::(ProgressParams { + token: token.clone(), + value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report( + WorkDoneProgressReport { + message: Some(message.into()), + percentage: percentage.map(|value| value.min(100)), + cancellable: Some(false), + }, + )), + }) + .await; + } + + pub(crate) async fn end(self, message: impl Into) { + let Some(token) = self.token else { return }; + self.client + .send_notification::(ProgressParams { + token, + value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd { + message: Some(message.into()), + })), + }) + .await; + } +} diff --git a/crates/server/src/scan.rs b/crates/server/src/scan.rs index 0b2e557..88aa187 100644 --- a/crates/server/src/scan.rs +++ b/crates/server/src/scan.rs @@ -1,22 +1,28 @@ //! Shared filesystem, BIG archive, and W3D workspace scanning. +use std::collections::{hash_map::DefaultHasher, HashMap, HashSet}; +use std::hash::{Hash, Hasher}; use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; +use percent_encoding::percent_decode_str; +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; +use zerosyntax_w3d::W3dFile; pub(crate) type ScanEntry = ( String, Vec, Vec, - Vec<(String, String)>, + Vec, Vec<(String, Vec)>, Vec<(String, String)>, Vec, @@ -24,6 +30,272 @@ pub(crate) type ScanEntry = ( Option>, ); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ScanProgress { + Discovering, + InputsDiscovered { + total: usize, + skipped: usize, + }, + Indexing { + done: usize, + total: usize, + cache_hits: usize, + cache_misses: usize, + }, + WritingCache, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ScanStats { + pub(crate) discovered_inputs: usize, + pub(crate) discovery_failures: usize, + pub(crate) cache_hits: usize, + pub(crate) cache_misses: usize, + pub(crate) fingerprint_failures: usize, + pub(crate) scan_failures: usize, + /// A usable persistent cache exists after the scan. This is also true + /// when an unchanged cache did not need to be written again. + pub(crate) cache_written: bool, + /// The persistent cache was created or replaced during this scan. + pub(crate) cache_updated: bool, +} + +impl ScanStats { + pub(crate) fn skipped_inputs(self) -> usize { + self.discovery_failures + self.fingerprint_failures + self.scan_failures + } +} + +pub(crate) struct ScanOutcome { + pub(crate) entries: Vec<(bool, ScanEntry)>, + pub(crate) stats: ScanStats, +} + +const INDEX_CACHE_VERSION: u32 = 5; +/// How many current-version caches `prune_index_caches` keeps, newest first. +const INDEX_CACHE_RETAINED: usize = 4; +/// How long a cache may sit unused before `prune_index_caches` drops it. +const INDEX_CACHE_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const MAX_PREVIEW_ASSET_BYTES: u64 = 128 * 1024 * 1024; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Fingerprint { + len: u64, + modified_secs: u64, + modified_nanos: u32, +} + +#[derive(Serialize, Deserialize)] +struct CachedEntry { + file: String, + definitions: Vec, + references: Vec, + tags: Vec, + object_models: Vec<(String, Vec)>, + object_parents: Vec<(String, String)>, + models: Vec, + assets: Vec, + text: Option, +} + +impl From<&ScanEntry> for CachedEntry { + fn from(entry: &ScanEntry) -> Self { + Self { + file: entry.0.clone(), + definitions: entry.1.clone(), + references: entry.2.clone(), + tags: entry.3.clone(), + object_models: entry.4.clone(), + object_parents: entry.5.clone(), + models: entry.6.clone(), + assets: entry.7.clone(), + text: entry.8.as_deref().map(str::to_owned), + } + } +} + +impl From for ScanEntry { + fn from(entry: CachedEntry) -> Self { + ( + entry.file, + entry.definitions, + entry.references, + entry.tags, + entry.object_models, + entry.object_parents, + entry.models, + entry.assets, + entry.text.map(Arc::from), + ) + } +} + +#[derive(Serialize, Deserialize)] +struct CachedFile { + fingerprint: Fingerprint, + entries: Vec, +} + +#[derive(Serialize, Deserialize)] +struct IndexCache { + version: u32, + schema_hash: u64, + files: HashMap, +} + +fn cache_dir() -> PathBuf { + #[cfg(windows)] + if let Some(path) = std::env::var_os("LOCALAPPDATA") { + return PathBuf::from(path).join("zerosyntax"); + } + #[cfg(not(windows))] + if let Some(path) = std::env::var_os("XDG_CACHE_HOME") { + return PathBuf::from(path).join("zerosyntax"); + } + std::env::temp_dir().join("zerosyntax") +} + +fn path_key(path: &Path) -> String { + let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let key = path.to_string_lossy().replace('\\', "/"); + if cfg!(windows) { + key.to_ascii_lowercase() + } else { + key + } +} + +fn schema_hash() -> u64 { + let mut hasher = DefaultHasher::new(); + zerosyntax_schema::EMBEDDED_SCHEMA_JSON.hash(&mut hasher); + hasher.finish() +} + +fn fingerprint(path: &Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?; + Some(Fingerprint { + len: metadata.len(), + modified_secs: modified.as_secs(), + modified_nanos: modified.subsec_nanos(), + }) +} + +/// Refresh the retention timestamp without rewriting a valid cache. +fn refresh_index_cache_last_used(path: &Path) -> bool { + match std::fs::OpenOptions::new() + .write(true) + .open(path) + .and_then(|file| file.set_modified(SystemTime::now())) + { + Ok(()) => true, + Err(error) => { + tracing::debug!(path = %path.display(), %error, "index cache last-used time could not be updated; falling back to cache rewrite"); + false + } + } +} + +pub(crate) fn index_cache_path(workspace_roots: &[PathBuf], base_roots: &[PathBuf]) -> PathBuf { + let mut roots: Vec<_> = workspace_roots + .iter() + .map(|root| format!("workspace:{}", path_key(root))) + .chain( + base_roots + .iter() + .map(|root| format!("base:{}", path_key(root))), + ) + .collect(); + roots.sort_unstable(); + let mut hasher = DefaultHasher::new(); + roots.hash(&mut hasher); + cache_dir().join(format!( + "index-v{INDEX_CACHE_VERSION}-{:016x}.json", + hasher.finish() + )) +} + +/// The cache version encoded in an `index-v-.json` file name, +/// or `None` for anything the server did not write as an index cache. +fn cache_file_version(name: &str) -> Option { + let (version, hash) = name + .strip_prefix("index-v")? + .strip_suffix(".json")? + .split_once('-')?; + (hash.len() == 16 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| version.parse().ok()) + .flatten() +} + +/// Delete index caches the server can no longer use — earlier cache +/// versions, and current-version caches unused for a while — down to +/// `INDEX_CACHE_RETAINED` files. `keep`, when given, is exempt and reserves a +/// retention slot; pass `None` if the caller didn't just write it +/// successfully, so a failed or partial write can't shield a stale file at +/// the expense of evicting a newer one. Unrecognized files are never +/// touched, and a failed delete only costs disk space. +fn prune_index_caches_in(dir: &Path, keep: Option<&Path>) -> usize { + let Ok(dir) = std::fs::read_dir(dir) else { + return 0; + }; + let now = SystemTime::now(); + let mut pruned = 0; + let mut remove = |path: &Path| match std::fs::remove_file(path) { + Ok(()) => pruned += 1, + Err(error) => { + tracing::debug!(path = %path.display(), %error, "stale index cache could not be removed") + } + }; + let mut current = Vec::new(); + for entry in dir.flatten() { + let path = entry.path(); + let Some(version) = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(cache_file_version) + else { + continue; + }; + if Some(path.as_path()) == keep { + continue; + } + let modified = entry.metadata().and_then(|data| data.modified()).ok(); + let expired = modified + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age > INDEX_CACHE_MAX_AGE); + if version != INDEX_CACHE_VERSION || expired { + remove(&path); + } else { + current.push((modified, path)); + } + } + current.sort_unstable_by_key(|(modified, _)| std::cmp::Reverse(*modified)); + let retained = INDEX_CACHE_RETAINED.saturating_sub(usize::from(keep.is_some())); + for (_, path) in current.into_iter().skip(retained) { + remove(&path); + } + pruned +} + +fn prune_index_caches(keep: Option<&Path>) -> usize { + prune_index_caches_in(&cache_dir(), keep) +} + +pub(crate) fn clear_index_cache( + workspace_roots: &[PathBuf], + base_roots: &[PathBuf], +) -> std::io::Result { + let path = index_cache_path(workspace_roots, base_roots); + let cleared = match std::fs::remove_file(&path) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error), + }; + prune_index_caches(None); + Ok(cleared) +} + struct BigEntry { name: String, offset: u64, @@ -135,7 +407,7 @@ fn file_stem_str(path: &str) -> String { .to_string() } -fn raw_asset(path: &str) -> Option { +fn raw_asset(path: &str, uri: &str) -> Option { let name = path.rsplit(['/', '\\']).next()?; let (_, extension) = name.rsplit_once('.')?; let kind = if extension.eq_ignore_ascii_case("wav") || extension.eq_ignore_ascii_case("mp3") { @@ -148,123 +420,28 @@ fn raw_asset(path: &str) -> Option { Some(FileAsset { kind, name: name.to_string(), + uri: uri.to_string(), }) } pub(crate) fn parse_w3d_models(bytes: &[u8], fallback_name: &str) -> Vec { - let mut names = Vec::new(); - let mut members = Vec::new(); - if !fallback_name.is_empty() { - names.push(fallback_name.to_string()); - } - walk_w3d_chunks(bytes, 0, bytes.len(), 0, &mut |kind, payload| match kind { - 0x0000_001F if payload.len() >= 40 => { - push_name(&mut members, read_fixed_name(&payload[8..24])); - push_name(&mut names, read_fixed_name(&payload[24..40])); - } - 0x0000_0101 | 0x0000_0501 | 0x0000_0601 if payload.len() >= 20 => { - push_name(&mut names, read_fixed_name(&payload[4..20])); - } - 0x0000_0102 => { - for pivot in payload.chunks_exact(60) { - push_name(&mut members, read_fixed_name(&pivot[..16])); - } - } - 0x0000_0701 if payload.len() >= 40 => { - push_name(&mut names, read_fixed_name(&payload[8..24])); - push_name(&mut names, read_fixed_name(&payload[24..40])); - } - 0x0000_0704 if payload.len() >= 36 => { - push_name(&mut members, read_fixed_name(&payload[4..36])); - } - 0x0000_0740 if payload.len() >= 40 => { - push_name(&mut members, read_fixed_name(&payload[8..40])); - } - 0x0000_0750 if payload.len() >= 48 => { - push_name(&mut members, read_fixed_name(&payload[16..48])); - } - _ => {} - }); - dedup_case_insensitive(&mut names); - dedup_case_insensitive(&mut members); - names - .into_iter() - .filter(|name| !name.is_empty()) - .map(|name| ModelAsset { - name, - members: members.clone(), - }) - .collect() -} - -const MAX_W3D_CHUNK_DEPTH: usize = 16; - -fn walk_w3d_chunks( - bytes: &[u8], - mut pos: usize, - end: usize, - depth: usize, - f: &mut impl FnMut(u32, &[u8]), -) { - if depth > MAX_W3D_CHUNK_DEPTH { - return; - } - while pos + 8 <= end && pos + 8 <= bytes.len() { - let kind = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap()); - let size_raw = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()); - let has_children = (size_raw & 0x8000_0000) != 0 || is_w3d_container(kind); - let size = (size_raw & 0x7fff_ffff) as usize; - let payload_start = pos + 8; - let Some(payload_end) = payload_start.checked_add(size) else { - break; - }; - if payload_end > end || payload_end > bytes.len() { - break; - } - let payload = &bytes[payload_start..payload_end]; - f(kind, payload); - if has_children { - walk_w3d_chunks(bytes, payload_start, payload_end, depth + 1, f); - } - pos = payload_end; - } -} - -fn is_w3d_container(kind: u32) -> bool { - matches!( - kind, - 0x0000_0000 - | 0x0000_0100 - | 0x0000_0500 - | 0x0000_0600 - | 0x0000_0700 - | 0x0000_0702 - | 0x0000_0705 - ) -} - -fn read_fixed_name(bytes: &[u8]) -> &str { - let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len()); - std::str::from_utf8(&bytes[..end]).unwrap_or("").trim() -} - -fn push_name(out: &mut Vec, name: &str) { - if name.is_empty() { - return; - } - out.push(name.to_string()); - if let Some((_, short)) = name.rsplit_once('.') { - if !short.is_empty() { - out.push(short.to_string()); - } + match W3dFile::parse(bytes) { + Ok(file) => file + .catalog(fallback_name) + .into_iter() + .map(|model| ModelAsset { + name: model.name, + members: model.members, + }) + .collect(), + Err(_) if !fallback_name.trim().is_empty() => vec![ModelAsset { + name: fallback_name.trim().to_string(), + members: Vec::new(), + }], + Err(_) => Vec::new(), } } -fn dedup_case_insensitive(values: &mut Vec) { - let mut seen = std::collections::HashSet::new(); - values.retain(|value| seen.insert(value.to_ascii_lowercase())); -} - pub(crate) fn scan_big(analyzer: &Analyzer, path: &Path) -> Result> { let mut out = Vec::new(); let mut assets = Vec::new(); @@ -309,10 +486,28 @@ pub(crate) fn scan_big(analyzer: &Analyzer, path: &Path) -> Result Result, + skipped: usize, +} + +/// Best-effort discovery used by tests and helpers that do not need the +/// skipped-entry count. Interactive indexing consumes `collect_paths` +/// directly so inaccessible configured inputs remain visible in ScanStats. +#[cfg(test)] pub(crate) fn collect_scan_paths(roots: &[PathBuf]) -> Vec { - collect_paths(roots, false).unwrap_or_default() + collect_paths(roots, false) + .map(|outcome| outcome.paths) + .unwrap_or_default() } /// Checked discovery used by the CLI, where skipped inputs must fail visibly. pub(crate) fn collect_scan_paths_checked(roots: &[PathBuf]) -> Result> { - collect_paths(roots, true) + collect_paths(roots, true).map(|outcome| outcome.paths) } -fn collect_paths(roots: &[PathBuf], checked: bool) -> Result> { +fn collect_paths(roots: &[PathBuf], checked: bool) -> Result { let mut out = Vec::new(); + let mut skipped = 0; for root in roots { + let root_start = out.len(); if root.is_file() && root .extension() @@ -355,7 +562,11 @@ fn collect_paths(roots: &[PathBuf], checked: bool) -> Result> { let entry = match entry { Ok(entry) => entry, Err(error) if checked => return Err(error.into()), - Err(_) => continue, + Err(error) => { + skipped += 1; + tracing::debug!(root = %root.display(), %error, "workspace walk entry skipped"); + continue; + } }; let path = entry.path(); if !path.is_file() { @@ -373,11 +584,45 @@ fn collect_paths(roots: &[PathBuf], checked: bool) -> Result> { out.push(path.to_path_buf()); } } + out[root_start..].sort_by(|left, right| { + let left_stem = left + .with_extension("") + .to_string_lossy() + .to_ascii_lowercase(); + let right_stem = right + .with_extension("") + .to_string_lossy() + .to_ascii_lowercase(); + left_stem.cmp(&right_stem).then_with(|| { + let rank = |path: &Path| { + path.extension() + .and_then(|value| value.to_str()) + .map_or(0, |extension| { + if extension.eq_ignore_ascii_case("dds") { + 1 + } else { + 0 + } + }) + }; + rank(left).cmp(&rank(right)) + }) + }); } - Ok(out) + if skipped > 0 { + tracing::warn!( + skipped_count = skipped, + "workspace walk skipped inaccessible entries" + ); + } + Ok(DiscoveryOutcome { + paths: out, + skipped, + }) } /// Best-effort indexing used by the interactive server. +#[cfg(test)] pub(crate) fn scan_files( analyzer: &Analyzer, paths: &[PathBuf], @@ -393,6 +638,203 @@ pub(crate) fn scan_files( out } +/// Scan workspace and base roots, reusing unchanged files from the persistent +/// asset index cache. Base entries stay first so workspace definitions retain +/// their existing override order. +pub(crate) fn scan_with_cache( + analyzer: &Analyzer, + workspace_roots: &[PathBuf], + base_roots: &[PathBuf], + progress: &mut impl FnMut(ScanProgress), +) -> ScanOutcome { + let started = Instant::now(); + progress(ScanProgress::Discovering); + let cache_path = index_cache_path(workspace_roots, base_roots); + let expected_schema_hash = schema_hash(); + let empty_cache = || IndexCache { + version: INDEX_CACHE_VERSION, + schema_hash: expected_schema_hash, + files: HashMap::new(), + }; + let (mut cache, cache_state) = match std::fs::read(&cache_path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (empty_cache(), "absent"), + Err(error) => { + tracing::debug!(path = %cache_path.display(), %error, "index cache could not be read"); + (empty_cache(), "corrupt") + } + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Err(error) => { + tracing::debug!(path = %cache_path.display(), %error, "index cache could not be parsed"); + (empty_cache(), "corrupt") + } + Ok(cache) + if cache.version != INDEX_CACHE_VERSION + || cache.schema_hash != expected_schema_hash => + { + tracing::debug!( + path = %cache_path.display(), + cache_version = cache.version, + expected_version = INDEX_CACHE_VERSION, + "index cache is stale" + ); + (empty_cache(), "stale") + } + Ok(cache) => (cache, "valid"), + }, + }; + + let mut discovered_inputs = 0; + let mut discovery_failures = 0; + let mut fingerprint_failures = 0; + let mut cache_hits = 0; + let mut cache_misses = 0; + let mut scan_failures = 0; + let mut seen = HashSet::new(); + let mut paths = Vec::new(); + for (roots, is_base) in [(base_roots, true), (workspace_roots, false)] { + let discovery = collect_paths(roots, false) + .expect("best-effort discovery converts walk errors into skipped entries"); + discovery_failures += discovery.skipped; + for path in discovery.paths { + let key = path_key(&path); + if seen.insert(key.clone()) { + discovered_inputs += 1; + if let Some(fingerprint) = fingerprint(&path) { + paths.push((path, key, fingerprint, is_base)); + } else { + fingerprint_failures += 1; + tracing::debug!( + path = %path.display(), + "workspace input skipped because it could not be fingerprinted" + ); + } + } + } + } + progress(ScanProgress::InputsDiscovered { + total: paths.len(), + skipped: discovery_failures + fingerprint_failures, + }); + + let cache_manifest_unchanged = cache_state == "valid" + && discovery_failures == 0 + && fingerprint_failures == 0 + && paths.len() == cache.files.len() + && paths.iter().all(|(_, key, fingerprint, _)| { + cache + .files + .get(key) + .is_some_and(|cached| cached.fingerprint == *fingerprint) + }); + let cache_unchanged = cache_manifest_unchanged && refresh_index_cache_last_used(&cache_path); + + let mut next = if cache_unchanged { + HashMap::new() + } else { + HashMap::with_capacity(paths.len()) + }; + let mut scanned = Vec::new(); + let total = paths.len(); + for (done, (path, key, fingerprint, is_base)) in paths.into_iter().enumerate() { + let entries = match cache.files.remove(&key) { + Some(cached) if cached.fingerprint == fingerprint => { + cache_hits += 1; + cached.entries.into_iter().map(ScanEntry::from).collect() + } + _ => { + cache_misses += 1; + match scan_path(analyzer, &path) { + Ok(entries) => entries, + Err(error) => { + scan_failures += 1; + tracing::debug!(path = %path.display(), %error, "workspace input could not be indexed"); + Vec::new() + } + } + } + }; + if !cache_unchanged { + next.insert( + key, + CachedFile { + fingerprint, + entries: entries.iter().map(CachedEntry::from).collect(), + }, + ); + } + scanned.extend(entries.into_iter().map(|entry| (is_base, entry))); + progress(ScanProgress::Indexing { + done: done + 1, + total, + cache_hits, + cache_misses, + }); + } + let mut cache_written = cache_unchanged; + let mut cache_updated = false; + if !cache_unchanged { + progress(ScanProgress::WritingCache); + let cache = IndexCache { + version: INDEX_CACHE_VERSION, + schema_hash: expected_schema_hash, + files: next, + }; + if let Some(parent) = cache_path.parent() { + if let Err(error) = std::fs::create_dir_all(parent).and_then(|()| { + serde_json::to_vec(&cache) + .map_err(std::io::Error::other) + .and_then(|bytes| std::fs::write(&cache_path, bytes)) + }) { + tracing::debug!(path = %cache_path.display(), %error, "asset index cache write failed"); + tracing::warn!(%error, "could not write asset index cache"); + } else { + cache_written = true; + cache_updated = true; + } + } + } + let pruned_caches = prune_index_caches(cache_written.then_some(cache_path.as_path())); + let skipped_count = fingerprint_failures + scan_failures; + if skipped_count > 0 { + tracing::warn!( + skipped_count, + fingerprint_failures, + scan_failures, + "workspace indexing skipped inputs" + ); + } + tracing::debug!( + path = %cache_path.display(), + cache_state, + discovered_inputs, + discovery_failures, + cache_hits, + cache_misses, + reparsed_files = cache_misses, + fingerprint_failures, + scan_failures, + produced_entries = scanned.len(), + cache_written, + pruned_caches, + cache_updated, + elapsed_ms = started.elapsed().as_millis() as u64, + "workspace scan completed" + ); + ScanOutcome { + entries: scanned, + stats: ScanStats { + discovered_inputs, + discovery_failures, + cache_hits, + cache_misses, + fingerprint_failures, + scan_failures, + cache_written, + cache_updated, + }, + } +} + pub(crate) fn scan_files_checked(analyzer: &Analyzer, paths: &[PathBuf]) -> Result> { let mut out = Vec::new(); for path in paths { @@ -445,7 +887,7 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { )) .into_iter() .collect()) - } else if let Some(asset) = raw_asset(&path.to_string_lossy()) { + } else if let Some(asset) = raw_asset(&path.to_string_lossy(), uri.as_str()) { Ok(vec![( uri.to_string(), Vec::new(), @@ -462,7 +904,260 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { } } +pub(crate) fn read_asset_uri(uri: &str) -> Result> { + let url = Url::parse(uri).with_context(|| format!("invalid asset URI `{uri}`"))?; + if url.scheme() == "file" { + let path = url + .to_file_path() + .map_err(|_| anyhow::anyhow!("invalid file asset URI `{uri}`"))?; + let len = std::fs::metadata(&path)?.len(); + if len > MAX_PREVIEW_ASSET_BYTES { + anyhow::bail!("asset exceeds 128 MiB"); + } + return std::fs::read(&path) + .with_context(|| format!("failed to read asset {}", path.display())); + } + if url.scheme() != "big" { + anyhow::bail!("unsupported asset URI scheme `{}`", url.scheme()); + } + let decoded = percent_decode_str(url.path()).decode_utf8_lossy(); + let (archive, entry_name) = decoded + .split_once("!/") + .ok_or_else(|| anyhow::anyhow!("invalid BIG asset URI `{uri}`"))?; + let archive = + if cfg!(windows) && archive.as_bytes().get(2) == Some(&b':') && archive.starts_with('/') { + &archive[1..] + } else { + archive + }; + let path = Path::new(archive); + let entry = big_entries(path)? + .into_iter() + .find(|entry| entry.name.eq_ignore_ascii_case(entry_name)) + .ok_or_else(|| anyhow::anyhow!("asset `{entry_name}` not found in {}", path.display()))?; + if entry.size as u64 > MAX_PREVIEW_ASSET_BYTES { + anyhow::bail!("asset exceeds 128 MiB"); + } + read_big_entry_bytes(path, &entry) +} + #[cfg(test)] pub(crate) fn scan_roots(analyzer: &Analyzer, roots: &[PathBuf]) -> Vec { scan_files(analyzer, &collect_scan_paths(roots), &mut |_, _| {}) } + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_temp_dir(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "zerosyntax-{label}-{}-{}", + std::process::id(), + UNIX_EPOCH.elapsed().unwrap().as_nanos() + )) + } + + #[test] + fn unchanged_warm_cache_is_not_rewritten() { + let root = unique_temp_dir("warm-cache"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("Weapon.ini"), "Weapon TestWeapon\nEnd\n").unwrap(); + let workspace_roots = vec![root.clone()]; + + let cold = scan_with_cache(&Analyzer::embedded(), &workspace_roots, &[], &mut |_| {}); + assert_eq!(cold.stats.cache_misses, 1); + assert!(cold.stats.cache_updated); + let cache_path = index_cache_path(&workspace_roots, &[]); + let old_last_used = SystemTime::now() - Duration::from_secs(24 * 60 * 60); + std::fs::OpenOptions::new() + .write(true) + .open(&cache_path) + .unwrap() + .set_modified(old_last_used) + .unwrap(); + + let mut warm_events = Vec::new(); + let warm = scan_with_cache(&Analyzer::embedded(), &workspace_roots, &[], &mut |event| { + warm_events.push(event) + }); + assert_eq!(warm.stats.cache_hits, 1); + assert_eq!(warm.stats.cache_misses, 0); + assert!(warm.stats.cache_written); + assert!(!warm.stats.cache_updated); + assert!(!warm_events.contains(&ScanProgress::WritingCache)); + assert!( + std::fs::metadata(&cache_path).unwrap().modified().unwrap() > old_last_used, + "using an unchanged cache refreshes its retention timestamp" + ); + + std::fs::write(root.join("Weapon.ini"), "Weapon UpdatedWeapon\nEnd\n").unwrap(); + let changed = scan_with_cache(&Analyzer::embedded(), &workspace_roots, &[], &mut |_| {}); + assert_eq!(changed.stats.cache_misses, 1); + assert!(changed.stats.cache_updated); + + let _ = clear_index_cache(&workspace_roots, &[]); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn failed_retention_refresh_rejects_the_no_rewrite_fast_path() { + let missing = unique_temp_dir("missing-cache").join("index.json"); + assert!(!missing.exists()); + assert!(!refresh_index_cache_last_used(&missing)); + } + + #[test] + fn discovery_failures_reach_progress_and_scan_stats() { + let missing = std::env::temp_dir().join(format!( + "zerosyntax-missing-{}-{}", + std::process::id(), + UNIX_EPOCH.elapsed().unwrap().as_nanos() + )); + assert!(!missing.exists()); + let workspace_roots = vec![missing]; + let mut events = Vec::new(); + let outcome = scan_with_cache(&Analyzer::embedded(), &workspace_roots, &[], &mut |event| { + events.push(event) + }); + + assert_eq!(outcome.stats.discovered_inputs, 0); + assert_eq!(outcome.stats.discovery_failures, 1); + assert_eq!(outcome.stats.skipped_inputs(), 1); + assert!(events.iter().any(|event| matches!( + event, + ScanProgress::InputsDiscovered { + total: 0, + skipped: 1 + } + ))); + + let _ = clear_index_cache(&workspace_roots, &[]); + } + + fn temp_cache_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "zerosyntax-prune-{name}-{}-{}", + std::process::id(), + UNIX_EPOCH.elapsed().unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Write a cache-shaped file `age` old, so retention order is deterministic + /// instead of dependent on filesystem timestamp granularity. + fn write_cache_file(dir: &Path, name: &str, age: Duration) -> PathBuf { + let path = dir.join(name); + let file = std::fs::File::create(&path).unwrap(); + file.set_modified(SystemTime::now() - age).unwrap(); + path + } + + #[test] + fn cache_file_version_accepts_only_generated_names() { + assert_eq!( + cache_file_version("index-v5-0123456789abcdef.json"), + Some(5) + ); + assert_eq!( + cache_file_version("index-v12-0123456789abcdef.json"), + Some(12) + ); + for name in [ + "index-v5-0123456789abcde.json", // hash too short + "index-v5-0123456789abcdefg.json", // not hexadecimal + "index-vX-0123456789abcdef.json", + "index-v5-0123456789abcdef.json.bak", + "notes.txt", + ] { + assert_eq!(cache_file_version(name), None, "{name}"); + } + } + + #[test] + fn pruning_drops_earlier_versions_and_keeps_recent_caches() { + let dir = temp_cache_dir("versions"); + let unrelated = write_cache_file(&dir, "notes.txt", Duration::ZERO); + let old_version = write_cache_file(&dir, "index-v1-00000000000000ff.json", Duration::ZERO); + let keep = write_cache_file( + &dir, + &format!("index-v{INDEX_CACHE_VERSION}-0000000000000000.json"), + Duration::ZERO, + ); + let others: Vec<_> = (1..=INDEX_CACHE_RETAINED as u64 + 2) + .map(|index| { + write_cache_file( + &dir, + &format!("index-v{INDEX_CACHE_VERSION}-{index:016x}.json"), + Duration::from_secs(index * 60), + ) + }) + .collect(); + + let pruned = prune_index_caches_in(&dir, Some(&keep)); + + assert!(keep.exists(), "the cache just written survives"); + assert!(unrelated.exists(), "unrelated files are never touched"); + assert!(!old_version.exists(), "earlier cache versions are dropped"); + let surviving = others.iter().filter(|path| path.exists()).count(); + assert_eq!(surviving, INDEX_CACHE_RETAINED - 1, "newest others survive"); + assert!(others[0].exists() && !others[others.len() - 1].exists()); + assert_eq!(pruned, 1 + others.len() - surviving); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn pruning_drops_caches_unused_past_the_age_limit() { + let dir = temp_cache_dir("age"); + let fresh = write_cache_file( + &dir, + &format!("index-v{INDEX_CACHE_VERSION}-000000000000000a.json"), + Duration::ZERO, + ); + let expired = write_cache_file( + &dir, + &format!("index-v{INDEX_CACHE_VERSION}-000000000000000b.json"), + INDEX_CACHE_MAX_AGE + Duration::from_secs(60), + ); + + // No `keep` (the cache write failed) still prunes. + let pruned = prune_index_caches_in(&dir, None); + + assert!(fresh.exists()); + assert!(!expired.exists()); + assert_eq!(pruned, 1); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn failed_write_does_not_reserve_a_retention_slot_for_the_stale_file() { + // A write failure leaves the previous file at `cache_path` in place. + // It must compete for a retention slot like any other cache, not + // reserve one and evict a newer file in its place. + let dir = temp_cache_dir("stale-keep"); + let stale = write_cache_file( + &dir, + &format!("index-v{INDEX_CACHE_VERSION}-0000000000000001.json"), + Duration::from_secs(600), + ); + let others: Vec<_> = (2..=INDEX_CACHE_RETAINED as u64 + 1) + .map(|index| { + write_cache_file( + &dir, + &format!("index-v{INDEX_CACHE_VERSION}-{index:016x}.json"), + Duration::from_secs(600 - index * 60), + ) + }) + .collect(); + + prune_index_caches_in(&dir, None); + + assert!(!stale.exists(), "the oldest file is evicted, not reserved"); + assert!( + others.iter().all(|path| path.exists()), + "newer files are not evicted to make room for the stale one" + ); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index a933838..9f1c9f1 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -9,11 +9,14 @@ failure. """ import json +import base64 +import os import subprocess import sys import threading import queue import struct +import urllib.parse def frame(obj: dict) -> bytes: @@ -49,6 +52,11 @@ def reader(stream, q: "queue.Queue"): q.put({"_parse_error": str(e)}) +def line_reader(stream, lines): + for line in iter(stream.readline, b""): + lines.append(line.decode("utf-8", "replace").rstrip()) + + def main() -> int: exe = sys.argv[1] @@ -59,6 +67,29 @@ def main() -> int: workspace = pathlib.Path(tempfile.mkdtemp(prefix="zerosyntax-e2e-")) (workspace / "Images.INI").write_text("MappedImage TestScanImage\nEnd\n") + + def w3d_chunk(kind, payload): + return struct.pack("III", archive_size, len(entries), 0)) + offset = data_offset + for name, content in entries.items(): + encoded_name = name.encode("ascii") + data.extend(struct.pack(">II", offset, len(content))) + data.extend(encoded_name + b"\0") + offset += len(content) + for content in entries.values(): + data.extend(content) + path.write_bytes(data) + + archive_entry = "Data/INI/Archived.ini" + write_big(archive, {archive_entry: archived_text.encode("utf-8")}) root_uri = workspace.as_uri() # vscode-languageclient appends this conventional transport flag. The @@ -78,13 +136,22 @@ def w3d_pivot(name): [exe, "--stdio"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, bufsize=0, + env={**os.environ, "RUST_LOG": "zerosyntax_lsp=debug"}, ) q: "queue.Queue" = queue.Queue() threading.Thread(target=reader, args=(proc.stdout, q), daemon=True).start() + stderr_lines = [] + stderr_thread = threading.Thread( + target=line_reader, args=(proc.stderr, stderr_lines), daemon=True + ) + stderr_thread.start() server_requests = [] indexing_begins = [] + progress_reports = [] + indexing_ends = [] + log_messages = [] def send(obj): proc.stdin.write(frame(obj)) @@ -101,6 +168,7 @@ def wait_for(pred, what, timeout=15.0): break if msg is None: break + assert "_parse_error" not in msg, msg if msg.get("method") in { "client/registerCapability", "client/unregisterCapability", @@ -111,6 +179,14 @@ def wait_for(pred, what, timeout=15.0): if (msg.get("method") == "$/progress" and msg.get("params", {}).get("value", {}).get("kind") == "begin"): indexing_begins.append(msg) + if (msg.get("method") == "$/progress" + and msg.get("params", {}).get("value", {}).get("kind") == "report"): + progress_reports.append(msg) + if (msg.get("method") == "$/progress" + and msg.get("params", {}).get("value", {}).get("kind") == "end"): + indexing_ends.append(msg) + if msg.get("method") == "window/logMessage": + log_messages.append(msg) if pred(msg): return msg print(f"TIMEOUT waiting for {what}", file=sys.stderr) @@ -118,6 +194,8 @@ def wait_for(pred, what, timeout=15.0): runtime_settings = { "format": {"enable": False}, + "preview": {"enable": True, "imageWidth": 240, "zoomPercent": 150}, + "progress": {"mode": "indexing"}, "baseIniRoots": [], "schema": {"path": ""}, "analysis": { @@ -140,12 +218,16 @@ def configure(): }, "workspaceFolders": None, "rootUri": root_uri, "initializationOptions": { "format": {"enable": False}, + "preview": {"enable": True, "imageWidth": 240, "zoomPercent": 150}, + "progress": {"mode": "indexing"}, "analysis": {"debounceMs": 50}, }}}) init = wait_for(lambda m: m.get("id") == 1 and "result" in m, "initialize result") assert init, "no initialize result" caps = init["result"]["capabilities"] assert "completionProvider" in caps, "missing completionProvider" + assert caps["completionProvider"].get("resolveProvider") is True, \ + "model previews require completionItem/resolve" assert "semanticTokensProvider" in caps, "missing semanticTokensProvider" sync = caps.get("textDocumentSync") assert sync == 2, f"expected INCREMENTAL sync (2), got {sync!r}" @@ -156,6 +238,36 @@ def configure(): print("OK: initialize advertised capabilities (incremental sync, utf-16)") send({"jsonrpc": "2.0", "method": "initialized", "params": {}}) + ready = wait_for( + lambda m: m.get("method") == "window/logMessage" + and "language server ready" in m.get("params", {}).get("message", ""), + "startup logging", + ) + assert ready and ready["params"]["type"] == 3, ready + startup_logs = [message["params"]["message"] for message in log_messages] + assert any("initializing v" in message for message in startup_logs), startup_logs + assert any( + "indexing started (reason=startup" in message for message in startup_logs + ), startup_logs + completed = next( + message + for message in startup_logs + if "indexing completed (reason=startup" in message + ) + assert "INI files" in completed and "W3D models" in completed, completed + startup_begin = indexing_begins[0]["params"]["value"] + assert startup_begin["title"] == "Starting ZeroSyntax", startup_begin + startup_progress = indexing_ends[0]["params"]["value"]["message"] + assert startup_progress.startswith("Ready"), startup_progress + assert "INI files" in startup_progress or "no indexable game data" in startup_progress + phase_messages = [ + message["params"]["value"].get("message", "") + for message in progress_reports + ] + assert any("Discovering workspace" in message for message in phase_messages), phase_messages + assert any("Activating workspace index" in message for message in phase_messages), phase_messages + assert any("Finalizing workspace state" in message for message in phase_messages), phase_messages + print("OK: startup emits initialization, indexing, and ready logs") # 2) didOpen with a Weapon block: bad bool + unknown field. uri = "file:///test/Weapon.ini" @@ -194,6 +306,44 @@ def configure(): assert "PrimaryDamage" in labels, f"expected PrimaryDamage in {labels[:10]}..." print(f"OK: completion returned {len(labels)} items incl. field names") + # 3b) Model completion stays small until resolve, then carries a PNG preview. + preview_uri = (workspace / "Preview.ini").as_uri() + preview_text = ( + "Object PreviewObject\n" + " Draw = W3DModelDraw ModuleTag_01\n" + " DefaultConditionState\n" + " Model = \n" + " End\n" + " End\n" + "End\n" + ) + send({"jsonrpc": "2.0", "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": preview_uri, "languageId": "generals-ini", + "version": 1, "text": preview_text}}}) + wait_for(lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == preview_uri, "preview diagnostics") + send({"jsonrpc": "2.0", "id": 100, "method": "textDocument/completion", + "params": {"textDocument": {"uri": preview_uri}, + "position": {"line": 3, "character": len(" Model = ")}}}) + preview_completion = wait_for( + lambda m: m.get("id") == 100 and "result" in m, "model completion") + preview_items = preview_completion["result"] + if isinstance(preview_items, dict): + preview_items = preview_items.get("items", []) + preview_item = next(item for item in preview_items if item["label"] == "Preview") + assert "documentation" not in preview_item, "preview rendered eagerly" + assert preview_item.get("data", {}).get("zerosyntax") == "w3d-model-preview" + send({"jsonrpc": "2.0", "id": 101, "method": "completionItem/resolve", + "params": preview_item}) + resolved = wait_for( + lambda m: m.get("id") == 101 and "result" in m, "model completion resolve") + markdown = resolved["result"]["documentation"]["value"] + assert len(markdown) < 100_000, "VS Code truncates longer Markdown payloads" + assert "|width=240)" in markdown + encoded_png = markdown.split("data:image/png;base64,", 1)[1].split("|", 1)[0] + assert base64.b64decode(encoded_png).startswith(b"\x89PNG\r\n\x1a\n") + print("OK: model completion resolves lazily to a textured PNG preview") + # 4) semantic tokens (full + range; the server must advertise range). assert caps["semanticTokensProvider"].get("range") is True, "range tokens not advertised" send({"jsonrpc": "2.0", "id": 3, "method": "textDocument/semanticTokens/full", @@ -307,6 +457,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)", @@ -471,6 +669,28 @@ def latest_burst_diag(message): timeout=2.0, ) assert "bad-percent" not in [d.get("code") for d in delayed["params"]["diagnostics"]] + + runtime_settings["preview"] = {"imageWidth": 320, "zoomPercent": 200} + configure() + send({"jsonrpc": "2.0", "id": 102, "method": "completionItem/resolve", + "params": preview_item}) + resized = wait_for( + lambda m: m.get("id") == 102 and "result" in m, "resized model preview") + resized_markdown = resized["result"]["documentation"]["value"] + resized_png = resized_markdown.split("data:image/png;base64,", 1)[1].split("|", 1)[0] + assert "|width=320)" in resized_markdown + assert resized_png != encoded_png, "zoom change reused the previous preview" + print("OK: model preview size and zoom hot-reload") + + runtime_settings["preview"]["enable"] = False + configure() + send({"jsonrpc": "2.0", "id": 103, "method": "completionItem/resolve", + "params": resolved["result"]}) + disabled = wait_for( + lambda m: m.get("id") == 103 and "result" in m, "disabled model preview") + assert "documentation" not in disabled["result"] + print("OK: model preview can be disabled without restarting") + runtime_settings["analysis"]["allowPercentagesWithoutSign"] = False configure() percent = wait_for( @@ -511,14 +731,26 @@ def latest_burst_diag(message): print("OK: debounce hot-reloads and publishes the current document version") progress_before = len(indexing_begins) - runtime_settings["baseIniRoots"] = [str(base)] + runtime_settings["baseIniRoots"] = [str(base), str(archive)] configure() wait_for( lambda m: m.get("method") == "$/progress" and m.get("params", {}).get("value", {}).get("kind") == "end", "base-root indexing", ) - assert len(indexing_begins) == progress_before + 1 + reindexed = next( + ( + message + for message in reversed(log_messages) + if "indexing completed (reason=configuration_changed" + in message.get("params", {}).get("message", "") + ), + None, + ) + assert reindexed and "audio files" in reindexed["params"]["message"], reindexed + assert len(indexing_begins) == progress_before + 1, ( + f"expected one base-root scan, got {len(indexing_begins) - progress_before}" + ) asset_uri = "file:///test/hot-assets.ini" open_doc(asset_uri, ("Object HotAssetObject\n ButtonImage = \nEnd\n" @@ -540,9 +772,105 @@ def completion_labels(doc_uri, line, character): items = items.get("items", []) return [item["label"] for item in items] - assert "HotBaseImage" in completion_labels(asset_uri, 1, 16) - assert "HotSound.wav" in completion_labels(asset_uri, 4, 13) - assert "HotTexture.tga" in completion_labels(asset_uri, 7, 12) + assert "HotBaseImage" in completion_labels(asset_uri, 1, 16), \ + "loose base INI definition missing" + assert "HotSound.wav" in completion_labels(asset_uri, 4, 13), \ + "base audio asset missing" + assert "HotTexture.tga" in completion_labels(asset_uri, 7, 12), \ + "base texture asset missing" + + archive_path = archive.as_posix() + if not archive_path.startswith("/"): + archive_path = "/" + archive_path + archived_uri = "big://" + urllib.parse.quote( + f"{archive_path}!/{archive_entry}", safe="/:!" + ) + send({"jsonrpc": "2.0", "id": 34, "method": "zerosyntax/readVirtualFile", + "params": {"uri": archived_uri}}) + canonical_virtual_file = wait_for( + lambda m: m.get("id") == 34 and "result" in m, + "canonical virtual file", + ) + assert canonical_virtual_file["result"] == archived_text, ( + archived_uri, canonical_virtual_file["result"] + ) + workspace_ref_uri = "file:///test/archive-reference.ini" + workspace_ref = open_doc( + workspace_ref_uri, + "Object ArchiveUser\n CommandSet = ArchivedSet\nEnd\n", + ) + assert "unresolved-reference" not in [ + diagnostic.get("code") for diagnostic in workspace_ref["diagnostics"] + ], workspace_ref["diagnostics"] + send({"jsonrpc": "2.0", "id": 35, "method": "textDocument/definition", + "params": {"textDocument": {"uri": workspace_ref_uri}, + "position": {"line": 1, "character": 20}}}) + archived_definition = wait_for( + lambda m: m.get("id") == 35 and "result" in m, + "workspace definition into BIG archive", + ) + assert archived_definition["result"] == [{ + "uri": archived_uri, + "range": { + "start": {"line": 3, "character": 11}, + "end": {"line": 3, "character": 22}, + }, + }], archived_definition["result"] + + parsed = urllib.parse.urlsplit(archived_uri) + vscode_path = urllib.parse.unquote(parsed.path) + if len(vscode_path) > 2 and vscode_path[2] == ":": + vscode_path = vscode_path[:1] + vscode_path[1].lower() + vscode_path[2:] + vscode_uri = "big:" + urllib.parse.quote(vscode_path, safe="/") + send({"jsonrpc": "2.0", "id": 36, "method": "zerosyntax/readVirtualFile", + "params": {"uri": vscode_uri}}) + virtual_file = wait_for( + lambda m: m.get("id") == 36 and "result" in m, + "VS Code-encoded virtual file", + ) + assert virtual_file["result"] == archived_text, virtual_file["result"] + + send({"jsonrpc": "2.0", "id": 37, "method": "zerosyntax/readVirtualFile", + "params": {"uri": archived_uri.replace("Archived.ini", "Unknown.ini")}}) + unknown_virtual = wait_for( + lambda m: m.get("id") == 37 and "result" in m, + "unknown virtual file", + ) + assert unknown_virtual["result"] is None + send({"jsonrpc": "2.0", "id": 38, "method": "zerosyntax/readVirtualFile", + "params": {"uri": "big:///C%3A/Game%FF/Base.big!/Data/INI/Archived.ini"}}) + malformed_virtual = wait_for( + lambda m: m.get("id") == 38 and "result" in m, + "malformed virtual file", + ) + assert malformed_virtual["result"] is None + + send({"jsonrpc": "2.0", "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": vscode_uri, "languageId": "generals-ini", + "version": 1, "text": archived_text}}}) + virtual_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == archived_uri, + "virtual document diagnostics", + ) + assert virtual_diag + send({"jsonrpc": "2.0", "id": 39, "method": "textDocument/definition", + "params": {"textDocument": {"uri": vscode_uri}, + "position": {"line": 4, "character": 10}}}) + nested_definition = wait_for( + lambda m: m.get("id") == 39 and "result" in m, + "definition from inside BIG archive", + ) + assert nested_definition["result"] == [{ + "uri": archived_uri, + "range": { + "start": {"line": 0, "character": 14}, + "end": {"line": 0, "character": 28}, + }, + }], nested_definition["result"] + send({"jsonrpc": "2.0", "method": "textDocument/didClose", + "params": {"textDocument": {"uri": vscode_uri}}}) + print("OK: BIG definitions open through encoded read-only URIs and navigate") model_uri = "file:///test/hot-model.ini" model_text = ("Object HotModelObject\n" @@ -596,6 +924,27 @@ def completion_labels(doc_uri, line, character): "custom-schema diagnostics", ) assert "unknown-block" not in [d.get("code") for d in custom["params"]["diagnostics"]] + runtime_settings["schema"]["path"] = str(workspace / "missing-schema.json") + configure() + schema_warning_log = wait_for( + lambda m: m.get("method") == "window/logMessage" + and m.get("params", {}).get("type") == 2 + and "custom schema could not be loaded" in m.get("params", {}).get("message", ""), + "invalid-schema warning log", + ) + schema_warning_popup = wait_for( + lambda m: m.get("method") == "window/showMessage" + and m.get("params", {}).get("type") == 2 + and "built-in schema" in m.get("params", {}).get("message", ""), + "invalid-schema warning popup", + ) + assert schema_warning_log and schema_warning_popup + custom = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == custom_uri, + "invalid-schema fallback diagnostics", + ) + assert "unknown-block" in [d.get("code") for d in custom["params"]["diagnostics"]] runtime_settings["schema"]["path"] = "" configure() custom = wait_for( @@ -604,7 +953,7 @@ def completion_labels(doc_uri, line, character): "embedded-schema diagnostics", ) assert "unknown-block" in [d.get("code") for d in custom["params"]["diagnostics"]] - print("OK: schema hot-reload reparses already-open documents") + print("OK: schema hot-reload logs invalid fallback and reparses open documents") runtime_settings["format"]["enable"] = True configure() @@ -629,14 +978,43 @@ def completion_labels(doc_uri, line, character): configure() wait_for(lambda m: m.get("method") == "client/registerCapability", "dynamic formatting re-registration") + wait_for( + lambda m: m.get("method") == "window/logMessage" + and "settings updated (format.enable)" in m.get("params", {}).get("message", ""), + "dynamic formatting settings log", + ) + + send({"jsonrpc": "2.0", "id": 30, "method": "workspace/executeCommand", + "params": {"command": "zerosyntax.rebuildIndexCache", "arguments": []}}) + rebuild_started = wait_for( + lambda m: m.get("method") == "window/logMessage" + and "indexing started (reason=manual_cache_rebuild" + in m.get("params", {}).get("message", ""), + "manual cache rebuild start log", + ) + rebuild_completed = wait_for( + lambda m: m.get("method") == "window/logMessage" + and "indexing completed (reason=manual_cache_rebuild" + in m.get("params", {}).get("message", ""), + "manual cache rebuild completion log", + ) + rebuild = wait_for(lambda m: m.get("id") == 30 and "result" in m, + "manual cache rebuild response") + assert rebuild_started and rebuild_completed and rebuild["result"]["rebuilt"] is True + assert indexing_begins[-1]["params"]["value"]["title"] == "Rebuilding ZeroSyntax index" + print("OK: manual cache rebuild logs its reason and completion") requests_before = len(server_requests) progress_before = len(indexing_begins) + logs_before = len(log_messages) configure() import time time.sleep(0.25) while not q.empty(): pending = q.get_nowait() + assert "_parse_error" not in pending, pending + if pending.get("method") == "window/logMessage": + log_messages.append(pending) assert pending.get("method") not in { "client/registerCapability", "client/unregisterCapability" }, pending @@ -644,8 +1022,57 @@ def completion_labels(doc_uri, line, character): and pending.get("params", {}).get("value", {}).get("kind") == "begin"), pending assert len(server_requests) == requests_before assert len(indexing_begins) == progress_before + assert len(log_messages) == logs_before print("OK: formatting hot-registers; identical settings are a no-op") + # Progress mode can suppress indexing UI without suppressing lifecycle logs. + runtime_settings["progress"]["mode"] = "off" + configure() + wait_for( + lambda m: m.get("method") == "window/logMessage" + and "settings updated (progress.mode)" in m.get("params", {}).get("message", ""), + "progress mode off", + ) + progress_before = len(indexing_begins) + runtime_settings["baseIniRoots"] = [str(base)] + configure() + hidden_reindex = wait_for( + lambda m: m.get("method") == "window/logMessage" + and "indexing completed (reason=configuration_changed" + in m.get("params", {}).get("message", ""), + "hidden base-root indexing", + ) + assert hidden_reindex + assert len(indexing_begins) == progress_before + + # Verbose mode adds progress for settings-driven whole-document refreshes. + runtime_settings["progress"]["mode"] = "verbose" + runtime_settings["analysis"]["mapOrderingDiagnostics"] = False + configure() + verbose_refresh = wait_for( + lambda m: m.get("method") == "$/progress" + and m.get("params", {}).get("value", {}).get("kind") == "begin" + and m["params"]["value"].get("title") == "Refreshing ZeroSyntax diagnostics", + "verbose diagnostic refresh", + ) + assert verbose_refresh + wait_for( + lambda m: m.get("method") == "$/progress" + and m.get("params", {}).get("value", {}).get("kind") == "end", + "verbose diagnostic refresh completion", + ) + runtime_settings["progress"]["mode"] = "indexing" + runtime_settings["analysis"]["mapOrderingDiagnostics"] = True + runtime_settings["baseIniRoots"] = [] + configure() + wait_for( + lambda m: m.get("method") == "window/logMessage" + and "indexing completed (reason=configuration_changed" + in m.get("params", {}).get("message", ""), + "restore base-root configuration", + ) + print("OK: progress mode supports off, indexing, and verbose behavior") + # 9) Phase-6 batch 2: semanticTokens delta, formatting, code actions. assert caps["semanticTokensProvider"]["full"] == {"delta": True}, \ caps["semanticTokensProvider"]["full"] @@ -727,6 +1154,17 @@ def pos_off(text, pos): # ASCII docs: utf-16 char == byte offset proc.wait(timeout=5) except Exception: proc.kill() + stderr_thread.join(timeout=2) + developer_logs = "\n".join(stderr_lines) + assert "workspace scan completed" in developer_logs, developer_logs + assert "document changed" in developer_logs, developer_logs + assert "document diagnostics published" in developer_logs, developer_logs + assert "parse_strategy=" in developer_logs, developer_logs + assert uri in developer_logs, developer_logs + assert workspace.name in developer_logs, developer_logs + assert "ScaleWeaponSpeed = Maybe" not in developer_logs, developer_logs + assert "Bogus = 1" not in developer_logs, developer_logs + print("OK: RUST_LOG debug records decisions and paths without document contents") # 10) a default-initialized server (no initializationOptions) must not # advertise formatting and must answer the request with null. diff --git a/crates/w3d/Cargo.toml b/crates/w3d/Cargo.toml new file mode 100644 index 0000000..f4c2aa1 --- /dev/null +++ b/crates/w3d/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "zerosyntax-w3d" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +glam.workspace = true +image.workspace = true diff --git a/crates/w3d/src/lib.rs b/crates/w3d/src/lib.rs new file mode 100644 index 0000000..ed4b0da --- /dev/null +++ b/crates/w3d/src/lib.rs @@ -0,0 +1,199 @@ +//! Small, renderer-agnostic reader for Command & Conquer W3D assets. +#![allow(clippy::manual_is_multiple_of)] // `is_multiple_of` is newer than the 1.75 MSRV. + +mod parse; +mod render; + +use std::fmt; + +pub use render::RenderedThumbnail; + +const MAX_FILE_BYTES: usize = 128 * 1024 * 1024; +const MAX_VERTICES: usize = 1_000_000; +const MAX_TRIANGLES: usize = 1_000_000; +const MAX_CHUNKS_PER_CONTAINER: usize = 1_000_000; +const MAX_CHUNK_DEPTH: usize = 16; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelCatalogEntry { + pub name: String, + pub members: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct W3dError(String); + +impl W3dError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for W3dError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for W3dError {} + +#[derive(Debug, Default)] +pub struct W3dFile { + pub(crate) meshes: Vec, + pub(crate) hierarchies: Vec, + pub(crate) hlods: Vec, + extra_names: Vec, + extra_members: Vec, +} + +impl W3dFile { + pub fn parse(bytes: &[u8]) -> Result { + parse::parse(bytes) + } + + pub fn catalog(&self, fallback_name: &str) -> Vec { + let mut names = Vec::new(); + let mut members = self.extra_members.clone(); + push_name(&mut names, fallback_name); + names.extend(self.extra_names.iter().cloned()); + for mesh in &self.meshes { + push_name(&mut names, &mesh.name); + push_name(&mut members, &mesh.name); + } + for hierarchy in &self.hierarchies { + push_name(&mut names, &hierarchy.name); + for pivot in &hierarchy.pivots { + push_name(&mut members, &pivot.name); + } + } + for hlod in &self.hlods { + push_name(&mut names, &hlod.name); + for subobject in hlod + .lods + .iter() + .flat_map(|lod| &lod.subobjects) + .chain(&hlod.aggregates) + { + push_name(&mut members, &subobject.name); + } + } + dedup(&mut names); + dedup(&mut members); + names + .into_iter() + .filter(|name| !name.is_empty()) + .map(|name| ModelCatalogEntry { + name, + members: members.clone(), + }) + .collect() + } + + pub fn render_thumbnail( + &self, + model: &str, + zoom: f32, + load_texture: impl FnMut(&str) -> Option>, + ) -> Result { + render::thumbnail(self, model, zoom, load_texture) + } +} + +fn push_name(out: &mut Vec, name: &str) { + let name = name.trim(); + if name.is_empty() { + return; + } + out.push(name.to_string()); + if let Some((_, short)) = name.rsplit_once('.') { + if !short.is_empty() { + out.push(short.to_string()); + } + } +} + +fn dedup(values: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + values.retain(|value| seen.insert(value.to_ascii_lowercase())); +} + +#[cfg(test)] +mod tests { + use image::GenericImageView; + + use super::*; + + fn chunk(kind: u32, payload: Vec) -> Vec { + let mut out = kind.to_le_bytes().to_vec(); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend(payload); + out + } + + fn floats(values: &[f32]) -> Vec { + values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() + } + + #[test] + fn parses_and_renders_a_textured_w3d() { + let mut header = vec![0; 116]; + header[8..16].copy_from_slice(b"Triangle"); + header[24..31].copy_from_slice(b"Preview"); + let mut triangle = Vec::new(); + triangle.extend([0u32, 1, 2].into_iter().flat_map(u32::to_le_bytes)); + triangle.extend_from_slice(&0u32.to_le_bytes()); + triangle.extend(floats(&[0.0, -1.0, 0.0, 0.0])); + let texture = chunk(0x30, chunk(0x31, chunk(0x32, b"Preview.tga\0".to_vec()))); + let mut material_info = vec![0; 32]; + material_info[8..12].copy_from_slice(&[255, 255, 255, 0]); + material_info[24..28].copy_from_slice(&1.0f32.to_le_bytes()); + let vertex_material = chunk(0x2a, chunk(0x2b, chunk(0x2d, material_info))); + let material = chunk(0x38, chunk(0x48, chunk(0x49, 0u32.to_le_bytes().to_vec()))); + let mesh = chunk( + 0, + [ + chunk(0x1f, header), + chunk( + 0x02, + floats(&[-1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]), + ), + chunk( + 0x03, + floats(&[0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0]), + ), + chunk(0x0d, floats(&[0.0, 0.0, 1.0, 0.0, 0.5, 1.0])), + chunk(0x20, triangle), + vertex_material, + texture, + material, + ] + .concat(), + ); + let mut tga = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 32, 0x20]; + tga.extend_from_slice(&[ + 0, 0, 255, 255, 0, 255, 0, 255, 255, 0, 0, 255, 255, 255, 255, 255, + ]); + + let file = W3dFile::parse(&mesh).unwrap(); + assert_eq!(file.meshes[0].material_diffuse, [255, 255, 255, 255]); + let rendered = file + .render_thumbnail("Preview", 1.0, |name| { + name.eq_ignore_ascii_case("Preview.tga") + .then(|| tga.clone()) + }) + .unwrap(); + assert!(rendered.missing_textures.is_empty()); + let image = image::load_from_memory(&rendered.png).unwrap(); + assert_eq!(image.dimensions(), (render::WIDTH, render::HEIGHT)); + assert!( + image + .to_rgb8() + .pixels() + .any(|pixel| pixel[0] != pixel[1] || pixel[1] != pixel[2]), + "expected textured geometry over the grayscale checkerboard" + ); + } +} diff --git a/crates/w3d/src/parse.rs b/crates/w3d/src/parse.rs new file mode 100644 index 0000000..95ac6cd --- /dev/null +++ b/crates/w3d/src/parse.rs @@ -0,0 +1,514 @@ +use glam::{Quat, Vec2, Vec3}; + +use crate::{ + push_name, W3dError, W3dFile, MAX_CHUNKS_PER_CONTAINER, MAX_CHUNK_DEPTH, MAX_FILE_BYTES, + MAX_TRIANGLES, MAX_VERTICES, +}; + +const MESH: u32 = 0x0000; +const VERTICES: u32 = 0x0002; +const NORMALS: u32 = 0x0003; +const TEXCOORDS: u32 = 0x000d; +const VERTEX_INFLUENCES: u32 = 0x000e; +const MESH_HEADER3: u32 = 0x001f; +const TRIANGLES: u32 = 0x0020; +const MATERIAL_INFO: u32 = 0x0028; +const VERTEX_MATERIALS: u32 = 0x002a; +const VERTEX_MATERIAL: u32 = 0x002b; +const VERTEX_MATERIAL_INFO: u32 = 0x002d; +const TEXTURES: u32 = 0x0030; +const TEXTURE: u32 = 0x0031; +const TEXTURE_NAME: u32 = 0x0032; +const MATERIAL_PASS: u32 = 0x0038; +const DCG: u32 = 0x003b; +const TEXTURE_STAGE: u32 = 0x0048; +const TEXTURE_IDS: u32 = 0x0049; +const STAGE_TEXCOORDS: u32 = 0x004a; +const PER_FACE_TEXCOORD_IDS: u32 = 0x004b; +const HIERARCHY: u32 = 0x0100; +const HIERARCHY_HEADER: u32 = 0x0101; +const PIVOTS: u32 = 0x0102; +const VERTEX_COLORS: u32 = 0x0115; +const HLOD: u32 = 0x0700; +const HLOD_HEADER: u32 = 0x0701; +const HLOD_LOD_ARRAY: u32 = 0x0702; +const HLOD_ARRAY_HEADER: u32 = 0x0703; +const HLOD_SUB_OBJECT: u32 = 0x0704; +const HLOD_AGGREGATES: u32 = 0x0705; + +#[derive(Debug, Default)] +pub(crate) struct Mesh { + pub name: String, + pub container: String, + pub attributes: u32, + pub vertices: Vec, + pub normals: Vec, + pub uvs: Vec, + pub triangles: Vec, + pub influences: Vec, + pub colors: Vec<[u8; 4]>, + pub material_diffuse: [u8; 4], + pub textures: Vec, + pub pass: MaterialPass, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Triangle { + pub indices: [u32; 3], +} + +#[derive(Debug, Default)] +pub(crate) struct MaterialPass { + pub colors: Vec<[u8; 4]>, + pub texture_ids: Vec, + pub uvs: Vec, + pub per_face_uv_ids: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct Hierarchy { + pub name: String, + pub pivots: Vec, +} + +#[derive(Debug)] +pub(crate) struct Pivot { + pub name: String, + pub parent: Option, + pub translation: Vec3, + pub rotation: Quat, +} + +#[derive(Debug, Default)] +pub(crate) struct Hlod { + pub name: String, + pub hierarchy: String, + pub lods: Vec, + pub aggregates: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct Lod { + pub max_screen_size: f32, + pub subobjects: Vec, +} + +#[derive(Debug)] +pub(crate) struct SubObject { + pub bone: usize, + pub name: String, +} + +struct Chunk<'a> { + kind: u32, + data: &'a [u8], +} + +pub(crate) fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_FILE_BYTES { + return Err(W3dError::new("W3D file exceeds 128 MiB")); + } + let mut file = W3dFile::default(); + for chunk in chunks(bytes, 0)? { + match chunk.kind { + MESH => file.meshes.push(parse_mesh(chunk.data)?), + HIERARCHY => file.hierarchies.push(parse_hierarchy(chunk.data)?), + HLOD => file.hlods.push(parse_hlod(chunk.data)?), + _ => {} + } + collect_catalog_names( + chunk.kind, + chunk.data, + &mut file.extra_names, + &mut file.extra_members, + 0, + )?; + } + let vertices: usize = file.meshes.iter().map(|mesh| mesh.vertices.len()).sum(); + let triangles: usize = file.meshes.iter().map(|mesh| mesh.triangles.len()).sum(); + if vertices > MAX_VERTICES || triangles > MAX_TRIANGLES { + return Err(W3dError::new("W3D geometry budget exceeded")); + } + Ok(file) +} + +fn parse_mesh(bytes: &[u8]) -> Result { + let mut mesh = Mesh { + material_diffuse: [204, 204, 204, 255], + ..Mesh::default() + }; + for chunk in chunks(bytes, 1)? { + match chunk.kind { + MESH_HEADER3 if chunk.data.len() >= 40 => { + mesh.attributes = u32_at(chunk.data, 4)?; + mesh.name = fixed_name(&chunk.data[8..24]); + mesh.container = fixed_name(&chunk.data[24..40]); + } + VERTICES => mesh.vertices = vec3s(chunk.data)?, + NORMALS => mesh.normals = vec3s(chunk.data)?, + TEXCOORDS => mesh.uvs = vec2s(chunk.data, true)?, + TRIANGLES => { + if chunk.data.len() % 32 != 0 { + return Err(W3dError::new("invalid triangle chunk size")); + } + mesh.triangles = chunk + .data + .chunks(32) + .map(|bytes| { + Ok(Triangle { + indices: [u32_at(bytes, 0)?, u32_at(bytes, 4)?, u32_at(bytes, 8)?], + }) + }) + .collect::>()?; + } + VERTEX_INFLUENCES => { + if chunk.data.len() % 8 != 0 { + return Err(W3dError::new("invalid vertex influence chunk size")); + } + mesh.influences = chunk + .data + .chunks(8) + .map(|value| u16::from_le_bytes([value[0], value[1]])) + .collect(); + } + VERTEX_COLORS => mesh.colors = rgba(chunk.data)?, + VERTEX_MATERIALS => parse_vertex_materials(chunk.data, &mut mesh)?, + TEXTURES => mesh.textures = parse_textures(chunk.data)?, + MATERIAL_PASS if mesh.pass.texture_ids.is_empty() => { + mesh.pass = parse_material_pass(chunk.data)?; + } + MATERIAL_INFO => {} + _ => {} + } + } + Ok(mesh) +} + +fn parse_vertex_materials(bytes: &[u8], mesh: &mut Mesh) -> Result<(), W3dError> { + for material in chunks(bytes, 2)? { + if material.kind != VERTEX_MATERIAL { + continue; + } + for chunk in chunks(material.data, 3)? { + if chunk.kind == VERTEX_MATERIAL_INFO && chunk.data.len() >= 11 { + mesh.material_diffuse[..3].copy_from_slice(&chunk.data[8..11]); + if chunk.data.len() >= 28 { + let opacity = f32_at(chunk.data, 24)?.clamp(0.0, 1.0); + mesh.material_diffuse[3] = (opacity * 255.0).round() as u8; + } + return Ok(()); + } + } + } + Ok(()) +} + +fn parse_textures(bytes: &[u8]) -> Result, W3dError> { + let mut textures = Vec::new(); + for texture in chunks(bytes, 2)? { + if texture.kind != TEXTURE { + continue; + } + let mut name = String::new(); + for chunk in chunks(texture.data, 3)? { + if chunk.kind == TEXTURE_NAME { + name = fixed_name(chunk.data); + } + } + textures.push(name); + } + Ok(textures) +} + +fn parse_material_pass(bytes: &[u8]) -> Result { + let mut pass = MaterialPass::default(); + for chunk in chunks(bytes, 2)? { + match chunk.kind { + DCG => pass.colors = rgba(chunk.data)?, + TEXTURE_STAGE if pass.texture_ids.is_empty() => { + for stage in chunks(chunk.data, 3)? { + match stage.kind { + TEXTURE_IDS => pass.texture_ids = u32s(stage.data)?, + STAGE_TEXCOORDS => pass.uvs = vec2s(stage.data, true)?, + PER_FACE_TEXCOORD_IDS => pass.per_face_uv_ids = u32s(stage.data)?, + _ => {} + } + } + } + _ => {} + } + } + Ok(pass) +} + +fn parse_hierarchy(bytes: &[u8]) -> Result { + let mut hierarchy = Hierarchy::default(); + for chunk in chunks(bytes, 1)? { + match chunk.kind { + HIERARCHY_HEADER if chunk.data.len() >= 20 => { + hierarchy.name = fixed_name(&chunk.data[4..20]); + } + PIVOTS => { + if chunk.data.len() % 60 != 0 { + return Err(W3dError::new("invalid pivot chunk size")); + } + for pivot in chunk.data.chunks(60) { + let parent = u32_at(pivot, 16)?; + let rotation = Quat::from_xyzw( + f32_at(pivot, 44)?, + f32_at(pivot, 48)?, + f32_at(pivot, 52)?, + f32_at(pivot, 56)?, + ); + hierarchy.pivots.push(Pivot { + name: fixed_name(&pivot[..16]), + parent: (parent != u32::MAX).then_some(parent as usize), + translation: vec3_at(pivot, 20)?, + rotation: if rotation.is_finite() && rotation.length_squared() > 0.0 { + rotation.normalize() + } else { + Quat::IDENTITY + }, + }); + } + } + _ => {} + } + } + Ok(hierarchy) +} + +fn parse_hlod(bytes: &[u8]) -> Result { + let mut hlod = Hlod::default(); + for chunk in chunks(bytes, 1)? { + match chunk.kind { + HLOD_HEADER if chunk.data.len() >= 40 => { + hlod.name = fixed_name(&chunk.data[8..24]); + hlod.hierarchy = fixed_name(&chunk.data[24..40]); + } + HLOD_LOD_ARRAY => hlod.lods.push(parse_lod(chunk.data)?), + HLOD_AGGREGATES => hlod.aggregates = parse_subobjects(chunk.data, 2)?, + _ => {} + } + } + Ok(hlod) +} + +fn parse_lod(bytes: &[u8]) -> Result { + let mut lod = Lod::default(); + for chunk in chunks(bytes, 2)? { + match chunk.kind { + HLOD_ARRAY_HEADER if chunk.data.len() >= 8 => { + lod.max_screen_size = f32_at(chunk.data, 4)?; + } + HLOD_SUB_OBJECT => lod.subobjects.push(parse_subobject(chunk.data)?), + _ => {} + } + } + Ok(lod) +} + +fn parse_subobjects(bytes: &[u8], depth: usize) -> Result, W3dError> { + chunks(bytes, depth)? + .into_iter() + .filter(|chunk| chunk.kind == HLOD_SUB_OBJECT) + .map(|chunk| parse_subobject(chunk.data)) + .collect() +} + +fn parse_subobject(bytes: &[u8]) -> Result { + if bytes.len() < 36 { + return Err(W3dError::new("invalid HLOD subobject")); + } + Ok(SubObject { + bone: u32_at(bytes, 0)? as usize, + name: fixed_name(&bytes[4..36]), + }) +} + +fn collect_catalog_names( + kind: u32, + bytes: &[u8], + names: &mut Vec, + members: &mut Vec, + depth: usize, +) -> Result<(), W3dError> { + match kind { + MESH_HEADER3 if bytes.len() >= 40 => { + push_name(members, &fixed_name(&bytes[8..24])); + push_name(names, &fixed_name(&bytes[24..40])); + } + HIERARCHY_HEADER | 0x0501 | 0x0601 if bytes.len() >= 20 => { + push_name(names, &fixed_name(&bytes[4..20])); + } + PIVOTS => { + for pivot in bytes.chunks(60).filter(|pivot| pivot.len() == 60) { + push_name(members, &fixed_name(&pivot[..16])); + } + } + HLOD_HEADER if bytes.len() >= 40 => { + push_name(names, &fixed_name(&bytes[8..24])); + push_name(names, &fixed_name(&bytes[24..40])); + } + HLOD_SUB_OBJECT if bytes.len() >= 36 => { + push_name(members, &fixed_name(&bytes[4..36])); + } + 0x0740 if bytes.len() >= 40 => push_name(members, &fixed_name(&bytes[8..40])), + 0x0750 if bytes.len() >= 48 => push_name(members, &fixed_name(&bytes[16..48])), + _ => {} + } + if depth >= MAX_CHUNK_DEPTH || !is_container(kind) { + return Ok(()); + } + for child in chunks(bytes, depth + 1)? { + collect_catalog_names(child.kind, child.data, names, members, depth + 1)?; + } + Ok(()) +} + +fn chunks(bytes: &[u8], depth: usize) -> Result>, W3dError> { + if depth > MAX_CHUNK_DEPTH { + return Err(W3dError::new("W3D chunk nesting exceeds 16")); + } + let mut out = Vec::new(); + let mut position = 0usize; + while position + 8 <= bytes.len() { + if out.len() == MAX_CHUNKS_PER_CONTAINER { + return Err(W3dError::new("W3D chunk count budget exceeded")); + } + let kind = u32_at(bytes, position)?; + let size = (u32_at(bytes, position + 4)? & 0x7fff_ffff) as usize; + let start = position + 8; + let end = start + .checked_add(size) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| W3dError::new("truncated W3D chunk"))?; + out.push(Chunk { + kind, + data: &bytes[start..end], + }); + position = end; + } + if bytes[position..].iter().any(|byte| *byte != 0) { + return Err(W3dError::new("trailing bytes after W3D chunk")); + } + Ok(out) +} + +fn is_container(kind: u32) -> bool { + matches!( + kind, + MESH | VERTEX_MATERIALS + | VERTEX_MATERIAL + | TEXTURES + | TEXTURE + | MATERIAL_PASS + | TEXTURE_STAGE + | HIERARCHY + | HLOD + | HLOD_LOD_ARRAY + | HLOD_AGGREGATES + | 0x0500 + | 0x0600 + ) +} + +fn fixed_name(bytes: &[u8]) -> String { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).trim().to_string() +} + +fn u32_at(bytes: &[u8], offset: usize) -> Result { + let value = bytes + .get(offset..offset + 4) + .ok_or_else(|| W3dError::new("truncated u32"))?; + Ok(u32::from_le_bytes(value.try_into().unwrap())) +} + +fn f32_at(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(u32_at(bytes, offset)?)) +} + +fn vec3_at(bytes: &[u8], offset: usize) -> Result { + Ok(Vec3::new( + f32_at(bytes, offset)?, + f32_at(bytes, offset + 4)?, + f32_at(bytes, offset + 8)?, + )) +} + +fn vec3s(bytes: &[u8]) -> Result, W3dError> { + if bytes.len() % 12 != 0 { + return Err(W3dError::new("invalid vector3 chunk size")); + } + (0..bytes.len()) + .step_by(12) + .map(|offset| vec3_at(bytes, offset)) + .collect() +} + +fn vec2s(bytes: &[u8], flip_v: bool) -> Result, W3dError> { + if bytes.len() % 8 != 0 { + return Err(W3dError::new("invalid vector2 chunk size")); + } + (0..bytes.len()) + .step_by(8) + .map(|offset| { + let u = f32_at(bytes, offset)?; + let v = f32_at(bytes, offset + 4)?; + Ok(Vec2::new(u, if flip_v { 1.0 - v } else { v })) + }) + .collect() +} + +fn u32s(bytes: &[u8]) -> Result, W3dError> { + if bytes.len() % 4 != 0 { + return Err(W3dError::new("invalid u32 array size")); + } + (0..bytes.len()) + .step_by(4) + .map(|offset| u32_at(bytes, offset)) + .collect() +} + +fn rgba(bytes: &[u8]) -> Result, W3dError> { + if bytes.len() % 4 != 0 { + return Err(W3dError::new("invalid RGBA array size")); + } + Ok(bytes + .chunks(4) + .map(|color| [color[0], color[1], color[2], color[3]]) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(kind: u32, payload: &[u8]) -> Vec { + let mut out = kind.to_le_bytes().to_vec(); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(payload); + out + } + + #[test] + fn parses_catalog_and_rejects_truncation() { + let mut header = vec![0; 116]; + header[8..12].copy_from_slice(b"Body"); + header[24..28].copy_from_slice(b"Tank"); + let mesh = chunk(MESH, &chunk(MESH_HEADER3, &header)); + let file = parse(&mesh).unwrap(); + assert!(file + .catalog("Fallback") + .iter() + .any(|model| model.name == "Tank")); + + let mut truncated = mesh; + truncated.pop(); + assert!(parse(&truncated).is_err()); + } +} diff --git a/crates/w3d/src/render.rs b/crates/w3d/src/render.rs new file mode 100644 index 0000000..ac38215 --- /dev/null +++ b/crates/w3d/src/render.rs @@ -0,0 +1,715 @@ +use std::collections::{HashMap, HashSet}; +use std::io::Cursor; + +use glam::{Mat4, Vec2, Vec3, Vec4}; +use image::{DynamicImage, ImageEncoder, ImageFormat, Rgba, RgbaImage}; + +use crate::parse::{Hierarchy, Mesh, SubObject}; +use crate::{W3dError, W3dFile}; + +pub const WIDTH: u32 = 160; +pub const HEIGHT: u32 = 120; +const HIDDEN: u32 = 0x0000_1000; +const TWO_SIDED: u32 = 0x0000_2000; +const COLLISION_MASK: u32 = 0x0000_0ff0; +const MAX_RENDER_TRIANGLES: usize = 10_000; +const MAX_RENDER_TEXTURES: usize = 32; +const MAX_RENDER_TEXTURE_PIXELS: u64 = 16 * 1024 * 1024; +const MAX_RASTER_SAMPLES: u64 = WIDTH as u64 * HEIGHT as u64 * 32; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedThumbnail { + pub png: Vec, + pub missing_textures: Vec, +} + +#[derive(Clone)] +struct Vertex { + position: Vec3, + normal: Vec3, + uv: Vec2, + color: Vec4, +} + +struct DrawTriangle { + vertices: [Vertex; 3], + texture: String, + two_sided: bool, +} + +struct Texture { + image: RgbaImage, +} + +pub(crate) fn thumbnail( + file: &W3dFile, + model: &str, + zoom: f32, + mut load_texture: impl FnMut(&str) -> Option>, +) -> Result { + let hierarchy = selected_hierarchy(file, model); + let bones = hierarchy.map(rest_pose).unwrap_or_default(); + let selected = selected_meshes(file, model); + let triangle_count = selected + .iter() + .try_fold(0usize, |total, (mesh, _)| { + total.checked_add(mesh.triangles.len()) + }) + .filter(|total| *total <= MAX_RENDER_TRIANGLES) + .ok_or_else(|| W3dError::new("model triangle budget exceeded"))?; + let mut triangles = Vec::with_capacity(triangle_count); + for (mesh, fallback_bone) in selected { + append_mesh_triangles(mesh, fallback_bone, &bones, &mut triangles); + } + if triangles.is_empty() { + return Err(W3dError::new("model has no visible triangles")); + } + + let mut textures = HashMap::new(); + let mut missing = Vec::new(); + let mut seen = HashSet::new(); + let mut texture_pixels = 0u64; + for name in triangles + .iter() + .map(|triangle| triangle.texture.as_str()) + .filter(|name| !name.is_empty()) + { + let key = name.to_ascii_lowercase(); + if seen.contains(&key) { + continue; + } + if seen.len() == MAX_RENDER_TEXTURES { + return Err(W3dError::new("model texture count budget exceeded")); + } + seen.insert(key.clone()); + let texture = load_texture(name) + .and_then(|bytes| decode_texture(&bytes).ok()) + .unwrap_or_else(|| { + missing.push(name.to_string()); + missing_texture() + }); + texture_pixels = texture_pixels + .checked_add(u64::from(texture.image.width()) * u64::from(texture.image.height())) + .filter(|pixels| *pixels <= MAX_RENDER_TEXTURE_PIXELS) + .ok_or_else(|| W3dError::new("model texture memory budget exceeded"))?; + textures.insert(key, texture); + } + + let mut pixels = checkerboard(); + let mut depth = vec![f32::INFINITY; (WIDTH * HEIGHT) as usize]; + rasterize( + &triangles, + &textures, + zoom.clamp(0.25, 4.0), + &mut pixels, + &mut depth, + )?; + let pixels = DynamicImage::ImageRgba8(pixels).into_rgb8(); + let mut png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut png) + .write_image(pixels.as_raw(), WIDTH, HEIGHT, image::ColorType::Rgb8) + .map_err(|error| W3dError::new(format!("could not encode preview: {error}")))?; + missing.sort_by_key(|name| name.to_ascii_lowercase()); + missing.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); + Ok(RenderedThumbnail { + png, + missing_textures: missing, + }) +} + +fn selected_hierarchy<'a>(file: &'a W3dFile, model: &str) -> Option<&'a Hierarchy> { + let requested = file + .hlods + .iter() + .find(|hlod| hlod.name.eq_ignore_ascii_case(model)) + .or_else(|| file.hlods.first()) + .map(|hlod| hlod.hierarchy.as_str()); + requested + .and_then(|name| { + file.hierarchies + .iter() + .find(|hierarchy| hierarchy.name.eq_ignore_ascii_case(name)) + }) + .or_else(|| file.hierarchies.first()) +} + +fn selected_meshes<'a>(file: &'a W3dFile, model: &str) -> Vec<(&'a Mesh, usize)> { + if let Some(hlod) = file + .hlods + .iter() + .find(|hlod| hlod.name.eq_ignore_ascii_case(model)) + { + let selected = meshes_for_hlod(file, hlod); + if !selected.is_empty() { + return selected; + } + } + + let matching: Vec<_> = file + .meshes + .iter() + .filter(|mesh| visible(mesh) && mesh_matches(mesh, model)) + .map(|mesh| (mesh, 0)) + .collect(); + if !matching.is_empty() { + return matching; + } + file.hlods + .first() + .map(|hlod| meshes_for_hlod(file, hlod)) + .filter(|selected| !selected.is_empty()) + .unwrap_or_else(|| { + file.meshes + .iter() + .filter(|mesh| visible(mesh)) + .map(|mesh| (mesh, 0)) + .collect() + }) +} + +fn meshes_for_hlod<'a>(file: &'a W3dFile, hlod: &'a crate::parse::Hlod) -> Vec<(&'a Mesh, usize)> { + let mut subobjects: Vec<&SubObject> = hlod.aggregates.iter().collect(); + if let Some(lod) = hlod + .lods + .iter() + .max_by(|left, right| left.max_screen_size.total_cmp(&right.max_screen_size)) + { + subobjects.extend(&lod.subobjects); + } + subobjects + .into_iter() + .filter_map(|subobject| { + find_mesh(&file.meshes, &subobject.name).map(|mesh| (mesh, subobject.bone)) + }) + .filter(|(mesh, _)| visible(mesh)) + .collect() +} + +fn visible(mesh: &Mesh) -> bool { + mesh.attributes & (HIDDEN | COLLISION_MASK) == 0 +} + +fn mesh_matches(mesh: &Mesh, name: &str) -> bool { + mesh.name.eq_ignore_ascii_case(name) + || mesh.container.eq_ignore_ascii_case(name) + || format!("{}.{}", mesh.container, mesh.name).eq_ignore_ascii_case(name) +} + +fn find_mesh<'a>(meshes: &'a [Mesh], name: &str) -> Option<&'a Mesh> { + let short = name + .rsplit_once('.') + .map(|(_, short)| short) + .unwrap_or(name); + meshes.iter().find(|mesh| { + mesh_matches(mesh, name) + || mesh.name.eq_ignore_ascii_case(short) + || format!("{}.{}", mesh.container, mesh.name).eq_ignore_ascii_case(name) + }) +} + +fn rest_pose(hierarchy: &Hierarchy) -> Vec { + let mut bones = Vec::with_capacity(hierarchy.pivots.len()); + for pivot in &hierarchy.pivots { + let local = Mat4::from_rotation_translation(pivot.rotation, pivot.translation); + let world = pivot + .parent + .and_then(|parent| bones.get(parent)) + .copied() + .unwrap_or(Mat4::IDENTITY) + * local; + bones.push(world); + } + bones +} + +fn append_mesh_triangles( + mesh: &Mesh, + fallback_bone: usize, + bones: &[Mat4], + out: &mut Vec, +) { + let uv_source = if mesh.uvs.is_empty() { + &mesh.pass.uvs + } else { + &mesh.uvs + }; + for (triangle_index, triangle) in mesh.triangles.iter().enumerate() { + let texture_id = match mesh.pass.texture_ids.as_slice() { + [only] => *only as usize, + many if triangle_index < many.len() => many[triangle_index] as usize, + _ => 0, + }; + let texture = mesh.textures.get(texture_id).cloned().unwrap_or_default(); + let mut vertices = Vec::with_capacity(3); + for corner in 0..3 { + let index = triangle.indices[corner] as usize; + let Some(position) = mesh.vertices.get(index).copied() else { + vertices.clear(); + break; + }; + let normal = mesh.normals.get(index).copied().unwrap_or(Vec3::Z); + let uv_index = mesh + .pass + .per_face_uv_ids + .get(triangle_index * 3 + corner) + .copied() + .map(|value| value as usize) + .unwrap_or(index); + let uv = uv_source.get(uv_index).copied().unwrap_or(Vec2::ZERO); + let color = mesh + .colors + .get(index) + .or_else(|| mesh.pass.colors.get(index)) + .copied() + .unwrap_or(mesh.material_diffuse); + let transform = mesh + .influences + .get(index) + .map(|bone| *bone as usize) + .or(Some(fallback_bone)) + .and_then(|bone| bones.get(bone)) + .copied() + .unwrap_or(Mat4::IDENTITY); + vertices.push(Vertex { + position: transform.transform_point3(position), + normal: transform.transform_vector3(normal).normalize_or_zero(), + uv, + color: Vec4::new( + color[0] as f32 / 255.0, + color[1] as f32 / 255.0, + color[2] as f32 / 255.0, + color[3] as f32 / 255.0, + ), + }); + } + if let Ok(vertices) = as TryInto<[Vertex; 3]>>::try_into(vertices) { + out.push(DrawTriangle { + vertices, + texture, + two_sided: mesh.attributes & TWO_SIDED != 0, + }); + } + } +} + +fn decode_texture(bytes: &[u8]) -> Result { + let format = image::guess_format(bytes).unwrap_or(ImageFormat::Tga); + let mut reader = image::io::Reader::with_format(Cursor::new(bytes), format); + let mut limits = image::io::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let decoded = reader + .decode() + .map_err(|error| W3dError::new(format!("unsupported texture: {error}")))?; + checked_texture(decoded) +} + +fn checked_texture(image: DynamicImage) -> Result { + if image.width() > 4096 + || image.height() > 4096 + || u64::from(image.width()) * u64::from(image.height()) * 4 > 64 * 1024 * 1024 + { + return Err(W3dError::new("texture budget exceeded")); + } + Ok(Texture { + image: image.to_rgba8(), + }) +} + +fn missing_texture() -> Texture { + let mut image = RgbaImage::new(8, 8); + for (x, y, pixel) in image.enumerate_pixels_mut() { + *pixel = if (x / 2 + y / 2) % 2 == 0 { + Rgba([255, 0, 255, 255]) + } else { + Rgba([25, 25, 25, 255]) + }; + } + Texture { image } +} + +fn checkerboard() -> RgbaImage { + let mut image = RgbaImage::new(WIDTH, HEIGHT); + for (x, y, pixel) in image.enumerate_pixels_mut() { + let value = if (x / 12 + y / 12) % 2 == 0 { 96 } else { 108 }; + *pixel = Rgba([value, value, value, 255]); + } + image +} + +fn rasterize( + triangles: &[DrawTriangle], + textures: &HashMap, + zoom: f32, + pixels: &mut RgbaImage, + depth: &mut [f32], +) -> Result<(), W3dError> { + let mut min = Vec3::splat(f32::INFINITY); + let mut max = Vec3::splat(f32::NEG_INFINITY); + for vertex in triangles.iter().flat_map(|triangle| &triangle.vertices) { + min = min.min(vertex.position); + max = max.max(vertex.position); + } + if !min.is_finite() || !max.is_finite() { + return Err(W3dError::new("model contains non-finite geometry")); + } + let center = (min + max) * 0.5; + let radius = triangles + .iter() + .flat_map(|triangle| &triangle.vertices) + .map(|vertex| vertex.position.distance(center)) + .fold(0.0f32, f32::max) + .max(0.01); + let direction = Vec3::new(1.0, -1.0, 0.75).normalize(); + let fov = 35.0f32.to_radians(); + let distance = radius / (fov * 0.5).tan() * 1.15; + let eye = center + direction * distance; + let view = Mat4::look_at_rh(eye, center, Vec3::Z); + let projection = Mat4::perspective_rh_gl( + fov, + WIDTH as f32 / HEIGHT as f32, + (distance - radius * 1.5).max(0.001), + distance + radius * 2.0, + ); + let view_projection = projection * view; + let light = Vec3::new(0.4, -0.7, 1.0).normalize(); + let mut raster_samples = MAX_RASTER_SAMPLES; + + for triangle in triangles { + let face = (triangle.vertices[1].position - triangle.vertices[0].position) + .cross(triangle.vertices[2].position - triangle.vertices[0].position); + if !triangle.two_sided && face.dot(eye - triangle.vertices[0].position) <= 0.0 { + continue; + } + let projected = triangle + .vertices + .clone() + .map(|vertex| project(vertex, view_projection, zoom)); + if projected.iter().any(|vertex| vertex.clip_w <= 0.0) { + continue; + } + draw_triangle( + &projected, + textures.get(&triangle.texture.to_ascii_lowercase()), + light, + pixels, + depth, + &mut raster_samples, + )?; + } + Ok(()) +} + +#[derive(Clone)] +struct Projected { + screen: Vec2, + depth: f32, + inv_w: f32, + uv_over_w: Vec2, + normal_over_w: Vec3, + color_over_w: Vec4, + clip_w: f32, +} + +fn project(vertex: Vertex, view_projection: Mat4, zoom: f32) -> Projected { + let clip = view_projection * vertex.position.extend(1.0); + let inv_w = 1.0 / clip.w; + let ndc = clip.truncate() * inv_w; + Projected { + screen: Vec2::new( + (ndc.x * zoom * 0.5 + 0.5) * (WIDTH - 1) as f32, + (1.0 - (ndc.y * zoom * 0.5 + 0.5)) * (HEIGHT - 1) as f32, + ), + depth: ndc.z, + inv_w, + uv_over_w: vertex.uv * inv_w, + normal_over_w: vertex.normal * inv_w, + color_over_w: vertex.color * inv_w, + clip_w: clip.w, + } +} + +fn draw_triangle( + vertices: &[Projected; 3], + texture: Option<&Texture>, + light: Vec3, + pixels: &mut RgbaImage, + depth: &mut [f32], + raster_samples: &mut u64, +) -> Result<(), W3dError> { + let area = edge(vertices[0].screen, vertices[1].screen, vertices[2].screen); + if area.abs() < 0.0001 { + return Ok(()); + } + let min_x = vertices + .iter() + .map(|vertex| vertex.screen.x) + .fold(f32::INFINITY, f32::min) + .floor() + .clamp(0.0, (WIDTH - 1) as f32) as u32; + let max_x = vertices + .iter() + .map(|vertex| vertex.screen.x) + .fold(f32::NEG_INFINITY, f32::max) + .ceil() + .clamp(0.0, (WIDTH - 1) as f32) as u32; + let min_y = vertices + .iter() + .map(|vertex| vertex.screen.y) + .fold(f32::INFINITY, f32::min) + .floor() + .clamp(0.0, (HEIGHT - 1) as f32) as u32; + let max_y = vertices + .iter() + .map(|vertex| vertex.screen.y) + .fold(f32::NEG_INFINITY, f32::max) + .ceil() + .clamp(0.0, (HEIGHT - 1) as f32) as u32; + if min_x > max_x || min_y > max_y { + return Ok(()); + } + let samples = u64::from(max_x - min_x + 1) * u64::from(max_y - min_y + 1); + *raster_samples = (*raster_samples) + .checked_sub(samples) + .ok_or_else(|| W3dError::new("model raster work budget exceeded"))?; + + for y in min_y..=max_y { + for x in min_x..=max_x { + let point = Vec2::new(x as f32 + 0.5, y as f32 + 0.5); + let bary = [ + edge(vertices[1].screen, vertices[2].screen, point) / area, + edge(vertices[2].screen, vertices[0].screen, point) / area, + edge(vertices[0].screen, vertices[1].screen, point) / area, + ]; + if bary.iter().any(|weight| *weight < -0.0001) { + continue; + } + let z = bary[0] * vertices[0].depth + + bary[1] * vertices[1].depth + + bary[2] * vertices[2].depth; + let offset = (y * WIDTH + x) as usize; + if z >= depth[offset] { + continue; + } + let denominator = bary[0] * vertices[0].inv_w + + bary[1] * vertices[1].inv_w + + bary[2] * vertices[2].inv_w; + if denominator <= 0.0 { + continue; + } + let interpolate_vec2 = |field: fn(&Projected) -> Vec2| { + (field(&vertices[0]) * bary[0] + + field(&vertices[1]) * bary[1] + + field(&vertices[2]) * bary[2]) + / denominator + }; + let interpolate_vec3 = |field: fn(&Projected) -> Vec3| { + (field(&vertices[0]) * bary[0] + + field(&vertices[1]) * bary[1] + + field(&vertices[2]) * bary[2]) + / denominator + }; + let interpolate_vec4 = |field: fn(&Projected) -> Vec4| { + (field(&vertices[0]) * bary[0] + + field(&vertices[1]) * bary[1] + + field(&vertices[2]) * bary[2]) + / denominator + }; + let uv = interpolate_vec2(|vertex| vertex.uv_over_w); + let normal = interpolate_vec3(|vertex| vertex.normal_over_w).normalize_or_zero(); + let vertex_color = interpolate_vec4(|vertex| vertex.color_over_w); + let texel = texture.map_or(Vec4::ONE, |texture| sample(texture, uv)); + let brightness = 0.35 + 0.65 * normal.dot(light).abs(); + let source = Vec4::new( + texel.x * vertex_color.x * brightness, + texel.y * vertex_color.y * brightness, + texel.z * vertex_color.z * brightness, + texel.w * vertex_color.w, + ) + .clamp(Vec4::ZERO, Vec4::ONE); + if source.w < 0.02 { + continue; + } + let destination = pixels.get_pixel(x, y).0; + let destination = Vec4::new( + destination[0] as f32 / 255.0, + destination[1] as f32 / 255.0, + destination[2] as f32 / 255.0, + destination[3] as f32 / 255.0, + ); + let output = source * source.w + destination * (1.0 - source.w); + pixels.put_pixel( + x, + y, + Rgba([ + (output.x * 255.0).round() as u8, + (output.y * 255.0).round() as u8, + (output.z * 255.0).round() as u8, + 255, + ]), + ); + if source.w >= 0.99 { + depth[offset] = z; + } + } + } + Ok(()) +} + +fn edge(a: Vec2, b: Vec2, point: Vec2) -> f32 { + (point.x - a.x) * (b.y - a.y) - (point.y - a.y) * (b.x - a.x) +} + +fn sample(texture: &Texture, uv: Vec2) -> Vec4 { + let x = (uv.x.rem_euclid(1.0) * (texture.image.width() - 1) as f32).round() as u32; + let y = (uv.y.rem_euclid(1.0) * (texture.image.height() - 1) as f32).round() as u32; + let pixel = texture.image.get_pixel(x, y).0; + Vec4::new( + pixel[0] as f32 / 255.0, + pixel[1] as f32 / 255.0, + pixel[2] as f32 / 255.0, + pixel[3] as f32 / 255.0, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse::{MaterialPass, Triangle}; + + #[test] + fn renders_a_bounded_png_and_reports_missing_texture() { + let file = W3dFile { + meshes: vec![Mesh { + name: "Triangle".into(), + vertices: vec![ + Vec3::new(-1.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(0.0, 0.0, 1.0), + ], + normals: vec![Vec3::Y; 3], + uvs: vec![Vec2::ZERO, Vec2::X, Vec2::Y], + triangles: vec![Triangle { indices: [0, 1, 2] }], + textures: vec!["missing.tga".into()], + material_diffuse: [255; 4], + pass: MaterialPass { + texture_ids: vec![0], + ..MaterialPass::default() + }, + ..Mesh::default() + }], + ..W3dFile::default() + }; + let rendered = thumbnail(&file, "Triangle", 1.0, |_| None).unwrap(); + let image = image::load_from_memory(&rendered.png).unwrap(); + assert_eq!((image.width(), image.height()), (WIDTH, HEIGHT)); + assert_eq!(rendered.missing_textures, vec!["missing.tga"]); + + let zoomed_out = thumbnail(&file, "Triangle", 0.5, |_| None).unwrap(); + let zoomed_in = thumbnail(&file, "Triangle", 2.0, |_| None).unwrap(); + let colored_pixels = |png: &[u8]| { + image::load_from_memory(png) + .unwrap() + .to_rgb8() + .pixels() + .filter(|pixel| pixel[0] != pixel[1] || pixel[1] != pixel[2]) + .count() + }; + assert!(colored_pixels(&zoomed_in.png) > colored_pixels(&zoomed_out.png)); + } + + #[test] + fn thumbnail_fits_vscode_markdown_limit_with_noisy_texture() { + let file = W3dFile { + meshes: vec![Mesh { + name: "Square".into(), + vertices: vec![ + Vec3::new(-1.0, 0.0, -1.0), + Vec3::new(1.0, 0.0, -1.0), + Vec3::new(1.0, 0.0, 1.0), + Vec3::new(-1.0, 0.0, 1.0), + ], + normals: vec![Vec3::NEG_Y; 4], + uvs: vec![Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y], + triangles: vec![ + Triangle { indices: [0, 1, 2] }, + Triangle { indices: [0, 2, 3] }, + ], + textures: vec!["noise.tga".into()], + material_diffuse: [255; 4], + pass: MaterialPass { + texture_ids: vec![0], + ..MaterialPass::default() + }, + ..Mesh::default() + }], + ..W3dFile::default() + }; + let mut tga = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 32, 0x20]; + for y in 0..256u32 { + for x in 0..256u32 { + let value = x.wrapping_mul(0x9e37_79b9) ^ y.wrapping_mul(0x85eb_ca6b); + tga.extend_from_slice(&[value as u8, (value >> 8) as u8, (value >> 16) as u8, 255]); + } + } + + let rendered = thumbnail(&file, "Square", 1.0, |_| Some(tga.clone())).unwrap(); + let base64_len = rendered.png.len().div_ceil(3) * 4; + assert!( + base64_len + 2_048 < 100_000, + "{} bytes of PNG becomes {base64_len} bytes of base64", + rendered.png.len() + ); + } + + #[test] + fn thumbnail_rejects_excessive_triangle_and_texture_work() { + let mesh = |triangle_count: usize, texture_count: usize| Mesh { + name: "Budget".into(), + vertices: vec![ + Vec3::new(-1.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(0.0, 0.0, 1.0), + ], + normals: vec![Vec3::NEG_Y; 3], + triangles: vec![Triangle { indices: [0, 1, 2] }; triangle_count], + textures: (0..texture_count) + .map(|index| format!("{index}.tga")) + .collect(), + material_diffuse: [255; 4], + pass: MaterialPass { + texture_ids: (0..triangle_count) + .map(|index| (index % texture_count.max(1)) as u32) + .collect(), + ..MaterialPass::default() + }, + ..Mesh::default() + }; + + let too_many_triangles = W3dFile { + meshes: vec![mesh(10_001, 0)], + ..W3dFile::default() + }; + assert!(thumbnail(&too_many_triangles, "Budget", 1.0, |_| None).is_err()); + + let too_much_raster_work = W3dFile { + meshes: vec![mesh(500, 0)], + ..W3dFile::default() + }; + assert!(thumbnail(&too_much_raster_work, "Budget", 1.0, |_| None).is_err()); + + let too_many_textures = W3dFile { + meshes: vec![mesh(33, 33)], + ..W3dFile::default() + }; + let mut loads = 0; + assert!(thumbnail(&too_many_textures, "Budget", 1.0, |_| { + loads += 1; + None + }) + .is_err()); + assert!(loads <= 32); + } +} 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 diff --git a/docs/language-server.md b/docs/language-server.md index ed15c88..384cdff 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -15,6 +15,38 @@ over stdio. The server writes protocol messages to stdout, so clients must launch it using stdio rather than a TCP port. +## Logging and troubleshooting + +The server sends concise lifecycle, configuration, indexing, and command +outcomes through LSP `window/logMessage`. In VS Code these appear under +**Output → ZeroSyntax v2 Language Server** at their proper Info, Warning, or +Error level. Long scans also keep their existing status-bar progress report. + +Developer detail uses structured `tracing` records on stderr and is controlled +with the standard `RUST_LOG` filter. The default is `warn`; enable server Debug +or Trace records before starting the editor: + +```powershell +$env:RUST_LOG = "zerosyntax_lsp=debug" # or zerosyntax_lsp=trace +code . +``` + +```sh +RUST_LOG=zerosyntax_lsp=debug code . # or zerosyntax_lsp=trace +``` + +The same filter works when launching `zerosyntax-lsp --stdio` from another LSP +client. Debug records include paths, document URIs, versions, timings, cache +decisions, parse strategies, and diagnostic counts. Info logs contain counts +instead of paths. Source text, INI values, completion contents, and document +excerpts are never logged. + +`zerosyntax.trace.server = verbose` is separate: it records the LSP requests +and responses themselves, while `RUST_LOG` explains internal server decisions. +Raw stderr can be decorated as an error by VS Code's language-client transport; +the level printed inside each tracing record is authoritative. Stdout is always +reserved for LSP framing and must never receive logs. + ## Command-line diagnostics Use the `check` subcommand to run the same parser, schema, workspace index, and @@ -84,6 +116,8 @@ symbols. ```json { "format": { "enable": false }, + "preview": { "imageWidth": 160, "zoomPercent": 100 }, + "progress": { "mode": "indexing" }, "schemaPath": "C:/Mods/MyMod/schema.json", "analysis": { "modelMemberStrictness": "compatible", @@ -101,6 +135,16 @@ symbols. - `format.enable` controls document formatting. It defaults to `false` and is dynamically registered when the client supports it. +- `preview.imageWidth` controls the displayed W3D thumbnail width in pixels + (`80`–`640`, default `160`). The client still owns the outer details-pane + bounds. +- `preview.zoomPercent` controls the default W3D camera zoom (`25`–`400`, + default `100`). Both preview settings apply to newly resolved completions + without restarting the server. +- `progress.mode` is `off`, `indexing` (the default), or `verbose`. The default + reports phased startup, schema/base-root reload, and manual rebuild progress; + `verbose` also reports analysis-setting diagnostic refreshes. `off` hides + status progress but retains lifecycle and error details in the client log. - `schemaPath` points to a custom schema JSON file. Unreadable or invalid files produce a warning and fall back to the built-in schema. - `analysis.modelMemberStrictness` is `off`, `compatible` (member exists in any @@ -120,13 +164,17 @@ symbols. INI spelling `stem.tga`. Audio and texture warnings activate independently only after that asset kind is indexed. Supply every loaded game/mod root to avoid warnings caused by a partial asset index. INI definitions are treated - as loaded before `map.ini` and `solo.ini`. + as loaded before `map.ini` and `solo.ini`. Definitions in loose files open + through native `file:` URIs. Definitions inside `.big` archives open as + read-only virtual INIs with navigation and inspection support when the client + selects the `big` URI scheme. The same settings can be sent at runtime through `workspace/didChangeConfiguration`, either directly or nested under `{"zerosyntax": ...}`. Analysis, debounce, and formatting changes apply immediately. Schema and base-root changes rebuild the complete index, keep -filesystem scanning on a blocking worker, and report indexing progress. +filesystem scanning on a blocking worker, and report progress through file +discovery, cache checking, index activation, and open-document diagnostics. Identical settings are ignored. Clients without dynamic formatting registration keep their startup formatting @@ -134,12 +182,38 @@ capability. If such a client starts with formatting disabled, it must restart to expose formatting; all other settings still hot-reload. Only selecting a different server executable inherently requires a new process. +## Persistent index cache + +Indexing results are cached on disk so a restart reuses unchanged files. The +cache lives in `%LOCALAPPDATA%\zerosyntax` on Windows and +`$XDG_CACHE_HOME/zerosyntax` (else the temp directory) elsewhere, as one +`index-v-.json` file per set of workspace and base roots. Every +cache-format bump, renamed workspace folder, or `baseIniRoots` change therefore +produces a new file. + +The server keeps that directory bounded: after each scan it deletes caches +written by an earlier cache version, caches unused for 30 days, and all but the +four most recently used current-version caches. The cache the running server +just wrote is always kept, and files it did not create are never touched. +Deleting the directory by hand is safe — the next scan rebuilds it. + ## Supported LSP features ZeroSyntax supports incremental document sync, diagnostics, completion, hover, go to definition, references, rename, semantic tokens, document and workspace symbols, folding ranges, quick fixes, and optional document formatting. +W3D model completion items support `completionItem/resolve`. Clients that render +Markdown completion documentation can show a lazy textured thumbnail +for the active `Model =` suggestion. The initial completion list contains no +image data. Previews use indexed loose or BIG-contained W3D/TGA/DDS assets and +cover mesh geometry, base materials, HLOD composition, and hierarchy bind pose. +`preview.imageWidth` changes its displayed size and `preview.zoomPercent` +changes the model framing. +Malformed or unsupported assets leave completion functional and show a short +preview-unavailable message. Rebuild the asset index or reload the server after +changing binary assets. + ## Build from source Install Rust 1.75 or newer, clone the repository, and run: diff --git a/docs/vscode-development.md b/docs/vscode-development.md index bdcba98..11cd5a5 100644 --- a/docs/vscode-development.md +++ b/docs/vscode-development.md @@ -15,9 +15,32 @@ npm ci npm test ``` -To debug the extension, open `editors/vscode` in VS Code and press F5. -The repository's launch configuration points the Extension Development Host at -the locally built debug server. +## Fast local loop (F5) + +Open the **repository root** in VS Code and press F5 (the +_Run Extension (local dev server)_ configuration in `.vscode/launch.json`). +This runs one build task that, in parallel: + +1. builds the debug server (`cargo build -p zerosyntax-server`), and +2. bundles the extension with source maps (`npm run compile:dev` in + `editors/vscode`). + +It then opens an Extension Development Host window with `ZEROSYNTAX_LSP_PATH` +pointed at `target/debug/zerosyntax-lsp[.exe]`, so the extension loads the +freshly built server without copying a binary or installing a `.vsix`. + +- **Changed the server?** Rebuild and reload: run the build task (or press + F5 again), then restart the language server from the dev-host + window (Command Palette → _Developer: Reload Window_). +- **Changed the extension (TypeScript)?** _Developer: Reload Window_ in the + dev-host picks up the rebuilt bundle; breakpoints work via the emitted source + maps. + +The first run needs `npm ci` in `editors/vscode` so the build task can find the +TypeScript/esbuild toolchain. + +For a lighter, server-only loop (no extension), see the Neovim harness in +[`editors/nvim/README.md`](../editors/nvim/README.md). ## Choose a server binary diff --git a/editors/nvim/README.md b/editors/nvim/README.md new file mode 100644 index 0000000..1a19495 --- /dev/null +++ b/editors/nvim/README.md @@ -0,0 +1,44 @@ +# Neovim development harness + +Test the locally built `zerosyntax-lsp` in Neovim without packaging or +installing anything. This is the fastest loop for **server-only** iteration +(diagnostics, completion, hover, go-to-definition); use the VS Code F5 workflow +in [`docs/vscode-development.md`](../../docs/vscode-development.md) when you also +need to exercise extension-side behaviour (W3D previews, commands, settings UI). + +## Quick, isolated run + +Build the server, then launch Neovim with the throwaway config in +[`dev-init.lua`](./dev-init.lua) — it loads no plugins and no user config: + +```sh +cargo build -p zerosyntax-server +nvim -u editors/nvim/dev-init.lua path/to/some.ini +``` + +Open an `.ini` file and the server attaches automatically. Useful checks: + +- `:checkhealth vim.lsp` or `:LspInfo` — confirm the `zerosyntax` client attached +- `:lua vim.diagnostic.open_float()` — inspect diagnostics under the cursor +- `K` / `gd` / `` — hover, go-to-definition, completion + +After changing server code, rebuild and reload: + +```sh +cargo build -p zerosyntax-server +``` + +then `:LspRestart` in the running Neovim (or reopen the file). + +## Use it inside your normal config + +To wire the local build into an existing setup, copy the `vim.filetype.add` +and `vim.lsp.start` block from `dev-init.lua` and point `exe` at your build: + +- debug build: `target/debug/zerosyntax-lsp[.exe]` +- release build: `target/release/zerosyntax-lsp[.exe]` + +Set `RUST_LOG=zerosyntax_lsp=debug` in the environment before launching Neovim +to get the server's structured trace on stderr (surfaced through +`:LspLog`). See [`docs/language-server.md`](../../docs/language-server.md) for +logging details and the full list of `init_options`. diff --git a/editors/nvim/dev-init.lua b/editors/nvim/dev-init.lua new file mode 100644 index 0000000..f385bf0 --- /dev/null +++ b/editors/nvim/dev-init.lua @@ -0,0 +1,64 @@ +-- Isolated Neovim harness for the locally built ZeroSyntax language server. +-- +-- Build first, then launch Neovim with this config (no plugins, no global +-- config), so you can test the server against a real .ini file: +-- +-- cargo build -p zerosyntax-server +-- nvim -u editors/nvim/dev-init.lua path/to/some.ini +-- +-- Re-test after a server change: rebuild, then `:LspRestart` (or reopen the +-- file). Requires Neovim 0.8+ (uses vim.lsp.start / vim.fs). + +local script = debug.getinfo(1, "S").source:sub(2) +local nvim_dir = vim.fn.fnamemodify(script, ":p:h") +local repo_root = vim.fn.fnamemodify(nvim_dir, ":h:h") +local is_win = vim.fn.has("win32") == 1 +local exe = repo_root .. "/target/debug/zerosyntax-lsp" .. (is_win and ".exe" or "") + +if vim.fn.filereadable(exe) == 0 then + vim.schedule(function() + vim.notify( + "zerosyntax-lsp not found at " .. exe .. "\nRun: cargo build -p zerosyntax-server", + vim.log.levels.ERROR + ) + end) +end + +-- The server expects the Generals/Zero Hour INI language, so map .ini to it. +vim.filetype.add({ extension = { ini = "generals-ini" } }) + +-- Resolve one root for the whole project so files in sibling directories share +-- a single server and its cross-file index (completion, definitions, rename). +-- Walk up to the mod/game root (the folder holding `Data` or `.git`); fall back +-- to the file's own directory when no marker is found. +local function project_root(fname) + local start = vim.fs.dirname(fname) + local marker = vim.fs.find({ "Data", ".git" }, { upward = true, path = start })[1] + if marker then + return vim.fs.dirname(marker) + end + return start +end + +vim.api.nvim_create_autocmd("FileType", { + pattern = "generals-ini", + callback = function(args) + local fname = vim.api.nvim_buf_get_name(args.buf) + vim.lsp.start({ + name = "zerosyntax", + cmd = { exe }, -- bare invocation speaks LSP over stdio + root_dir = project_root(fname), + -- Mirrors the VS Code extension's initializationOptions; see + -- docs/language-server.md for the full list. + init_options = { + format = { enable = false }, + -- baseIniRoots = { "C:/Games/Command and Conquer Generals Zero Hour" }, + analysis = { + modelMemberStrictness = "compatible", + mapOrderingDiagnostics = true, + debounceMs = 250, + }, + }, + }) + end, +}) diff --git a/editors/vscode/README.md b/editors/vscode/README.md index b2a91ba..49cb224 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -33,16 +33,28 @@ WAV/MP3 audio, and TGA/DDS texture checks. Configure every loaded game/mod root; asset warnings activate per kind once any matching asset is indexed. DDS-only textures are offered using the engine-compatible `stem.tga` spelling. +While completing `Model =`, move the active selection with the keyboard or +mouse to see a textured W3D thumbnail in VS Code's suggestion-details pane. +The preview is loaded only for the selected entry. If the pane is collapsed, +press `Ctrl+Space` again or use the suggestion widget's details control. +Disable `zerosyntax.preview.enable` to avoid model rendering and cached images +on lower-end hardware. Use `zerosyntax.preview.imageWidth` to change the thumbnail/pane content width +and `zerosyntax.preview.zoomPercent` to change the model framing. + ## Settings | Setting | Default | Purpose | | --- | --- | --- | | `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for INI and game-asset checks; changes reindex. | +| `zerosyntax.progress.mode` | `indexing` | Shows indexing progress by default; `verbose` also reports settings-driven diagnostic refreshes, and `off` hides status progress while keeping Output logs. | | `zerosyntax.schema.path` | empty | Custom schema JSON; changes reparse and reindex, with invalid files falling back to the built-in schema. | | `zerosyntax.analysis.modelMemberStrictness` | `compatible` | Disables member warnings, accepts any applicable model, or requires every model; applies immediately. | | `zerosyntax.analysis.allowPercentagesWithoutSign` | `false` | Allows engine-compatible percentage values without a trailing `%`; applies immediately. | | `zerosyntax.analysis.mapOrderingDiagnostics` | `true` | Warns about source-proven forward-order problems in `map.ini` and `solo.ini`; applies immediately. | | `zerosyntax.analysis.debounceMs` | `250` | Delay before diagnostics/index refresh after typing; applies to future edits immediately. | +| `zerosyntax.preview.enable` | `true` | Enables rendered W3D model completion previews; disable on lower-end hardware. | +| `zerosyntax.preview.imageWidth` | `160` | Displayed W3D thumbnail width in pixels; applies to newly resolved previews immediately. | +| `zerosyntax.preview.zoomPercent` | `100` | Default W3D preview camera zoom; applies to newly resolved previews immediately. | | `zerosyntax.format.enable` | `false` | Enables indentation formatting immediately when the client supports dynamic registration. | | `zerosyntax.server.path` | empty | Uses a custom `zerosyntax-lsp` binary instead of the bundled one; changing it restarts the server. | | `zerosyntax.trace.server` | `off` | Logs LSP traffic for troubleshooting. | @@ -51,8 +63,9 @@ Formatting is intentionally off by default. Enable it only when you want **Format Document** or format-on-save to normalize indentation. Runtime settings reload without restarting. Schema and base-root changes show -indexing progress because they rebuild workspace state; only changing the -server executable path requires a normal VS Code language-server restart. +phased progress through discovery, indexing, activation, and diagnostics unless +`zerosyntax.progress.mode` is `off`; only changing the server executable path +requires a normal VS Code language-server restart. ## INI file association @@ -79,8 +92,11 @@ Use the language selector in VS Code's status bar to change an individual file. - If map references are reported as missing, configure `zerosyntax.baseIniRoots` and check that the paths point to the required INI folders or `.big` archives. -- For detailed logs, set `zerosyntax.trace.server` to `messages` or `verbose`, - reproduce the problem, then open **Output → ZeroSyntax v2 Language Server**. +- Start with the operational log under **Output → ZeroSyntax v2 Language + Server**. Set `zerosyntax.trace.server` to `verbose` for protocol traffic. + For internal cache, parse, and diagnostic decisions, launch VS Code with + `RUST_LOG=zerosyntax_lsp=debug` (PowerShell: + `$env:RUST_LOG = "zerosyntax_lsp=debug"; code .`). Report reproducible problems through [GitHub Issues](https://github.com/ViTeXFTW/ZeroSyntaxV2/issues) with a minimal diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index f62869a..badd0a5 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -9,17 +9,17 @@ "version": "1.0.4", "license": "MIT", "dependencies": { - "vscode-languageclient": "^9.0.1" + "vscode-languageclient": "^10.1.0" }, "devDependencies": { "@types/mocha": "^10.0.10", - "@types/node": "^20.0.0", + "@types/node": "^26.2.0", "@types/vscode": "^1.84.0", - "@vscode/test-electron": "^3.0.0", + "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", - "esbuild": "^0.28.1", - "mocha": "^11.3.0", - "typescript": "^6.0.3" + "esbuild": "^0.28.2", + "mocha": "^11.8.0", + "typescript": "^7.0.2" }, "engines": { "vscode": "^1.84.0" @@ -236,9 +236,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -253,9 +253,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -270,9 +270,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -287,9 +287,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -304,9 +304,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -321,9 +321,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -338,9 +338,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -355,9 +355,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -372,9 +372,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -389,9 +389,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -406,9 +406,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -423,9 +423,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -440,9 +440,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -457,9 +457,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -474,9 +474,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -491,9 +491,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -508,9 +508,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -525,9 +525,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -542,9 +542,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -559,9 +559,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -576,9 +576,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -593,9 +593,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -610,9 +610,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -627,9 +627,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -644,9 +644,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -661,9 +661,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1079,13 +1079,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/normalize-package-data": { @@ -1109,6 +1109,346 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", @@ -1125,9 +1465,9 @@ } }, "node_modules/@vscode/test-electron": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.0.0.tgz", - "integrity": "sha512-TY5mC7aAjxSLDXsyjhrG8cJHgc/HLdiE5lvtW7hABYQrY24Qwozzr5UoO3HiuAM4Hzz4b7K/eZlwrCILj94CcA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.1.0.tgz", + "integrity": "sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw==", "dev": true, "license": "MIT", "dependencies": { @@ -1444,7 +1784,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -1535,7 +1874,6 @@ "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2273,9 +2611,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2286,32 +2624,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -3479,7 +3817,6 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -3521,9 +3858,9 @@ "optional": true }, "node_modules/mocha": { - "version": "11.7.6", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", - "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", + "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", "dev": true, "license": "MIT", "dependencies": { @@ -5209,17 +5546,38 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/uc.micro": { @@ -5247,9 +5605,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -5315,69 +5673,49 @@ } }, "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", + "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageclient": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", - "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", - "license": "MIT", - "dependencies": { - "minimatch": "^5.1.0", - "semver": "^7.3.7", - "vscode-languageserver-protocol": "3.17.5" - }, - "engines": { - "vscode": "^1.82.0" - } - }, - "node_modules/vscode-languageclient/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/vscode-languageclient/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.1.0.tgz", + "integrity": "sha512-XXRx6lqVitQy/oOLr9MfNYRG+MbQkhXkDaxbQMiKxEm8zZNfheRFUKNb8UYNh2stn9btl2wQM5wZFJjJvoc+jA==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/vscode-languageclient/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" + "minimatch": "^10.2.5", + "semver": "^7.8.1", + "vscode-languageserver-protocol": "3.18.2", + "vscode-languageserver-textdocument": "1.0.13" }, "engines": { - "node": ">=10" + "vscode": "^1.91.0" } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "version": "3.18.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", + "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", "license": "MIT", "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" + "vscode-jsonrpc": "9.0.1", + "vscode-languageserver-types": "3.18.0" } }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.13.tgz", + "integrity": "sha512-nx0ZHwMGIsVkzFG3/VLeJYBLTaFBRuNdGDvevvjuoayU5EOS2fEYazOhtCM3PI9ClMMg5igc0uwXtAq4tJj+Dw==", + "license": "MIT" + }, "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", + "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", "license": "MIT" }, "node_modules/whatwg-encoding": { diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 8e4a1fb..1221f0d 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "zerosyntax-vscode", "displayName": "ZeroSyntax v2", "description": "Language support (diagnostics, completion, semantic highlighting) for C&C Generals: Zero Hour INI files, powered by ZeroSyntax v2.", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "repository": { "type": "GitHub", @@ -19,10 +19,25 @@ ], "main": "./out/extension.js", "activationEvents": [ - "onLanguage:generals-ini" + "onLanguage:generals-ini", + "onCommand:zerosyntax.clearIndexCache", + "onCommand:zerosyntax.rebuildIndexCache", + "onCommand:zerosyntax.openIndexCacheLocation" ], "contributes": { "commands": [ + { + "command": "zerosyntax.clearIndexCache", + "title": "ZeroSyntax: Clear Index Cache" + }, + { + "command": "zerosyntax.rebuildIndexCache", + "title": "ZeroSyntax: Rebuild Index Cache" + }, + { + "command": "zerosyntax.openIndexCacheLocation", + "title": "ZeroSyntax: Open Index Cache Location" + }, { "command": "zerosyntax.selectSchema", "title": "ZeroSyntax: Select Custom Schema" @@ -62,6 +77,25 @@ "default": false, "markdownDescription": "Enable document formatting (indentation normalization). When off — the default — `#editor.formatOnSave#` will not invoke it for Generals INI files." }, + "zerosyntax.preview.enable": { + "type": "boolean", + "default": true, + "markdownDescription": "Show rendered W3D model images in completion details. Disable this on lower-end hardware to avoid model rendering and cached preview images." + }, + "zerosyntax.preview.imageWidth": { + "type": "integer", + "default": 160, + "minimum": 80, + "maximum": 640, + "markdownDescription": "Width in pixels of the W3D model image in completion details. VS Code sizes the details pane around the image within the available editor space." + }, + "zerosyntax.preview.zoomPercent": { + "type": "integer", + "default": 100, + "minimum": 25, + "maximum": 400, + "markdownDescription": "Default camera zoom for W3D completion previews, as a percentage. Values above 100 show the model closer; values below 100 add space around it." + }, "zerosyntax.baseIniRoots": { "type": "array", "default": [], @@ -70,6 +104,17 @@ }, "markdownDescription": "Directories or `.big` archives containing base game/mod INI files and game assets. INI definitions are treated as already loaded before `map.ini`/`solo.ini`; W3D, WAV/MP3, and TGA/DDS assets power completions and diagnostics. Configure all loaded roots to avoid partial-index warnings. Changing this reindexes in the background." }, + "zerosyntax.progress.mode": { + "type": "string", + "enum": ["off", "indexing", "verbose"], + "enumDescriptions": [ + "Hide status-bar progress. Lifecycle and error details remain available in the language-server Output channel.", + "Show startup, schema/base-root indexing, and manual index-rebuild progress.", + "Also show whole-document diagnostics refreshes caused by analysis setting changes." + ], + "default": "indexing", + "markdownDescription": "Controls ZeroSyntax work progress in the editor. `indexing` preserves the normal startup and reindexing feedback; `verbose` also reports potentially expensive diagnostic refreshes; `off` keeps these operations silent while retaining Output logs." + }, "zerosyntax.schema.path": { "type": "string", "default": "", @@ -113,6 +158,7 @@ }, "scripts": { "compile": "tsc --noEmit && esbuild src/extension.ts --bundle --minify --legal-comments=linked --platform=node --external:vscode --outfile=out/extension.js", + "compile:dev": "tsc --noEmit && esbuild src/extension.ts --bundle --sourcemap --platform=node --external:vscode --outfile=out/extension.js", "compile:test": "tsc -p ./tsconfig.test.json", "watch": "esbuild src/extension.ts --bundle --platform=node --external:vscode --outfile=out/extension.js --watch", "vscode:prepublish": "node -e \"const fs=require('fs');fs.mkdirSync('icon',{recursive:true});fs.copyFileSync('../../icon/ZeroSyntaxLogo256.png','icon/ZeroSyntaxLogo256.png')\" && npm run compile", @@ -120,16 +166,16 @@ "test": "npm run compile && npm run compile:test && node ./out-test/src/test/runTest.js" }, "dependencies": { - "vscode-languageclient": "^9.0.1" + "vscode-languageclient": "^10.1.0" }, "devDependencies": { "@types/mocha": "^10.0.10", - "@types/node": "^20.0.0", + "@types/node": "^26.2.0", "@types/vscode": "^1.84.0", - "@vscode/test-electron": "^3.0.0", + "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", - "esbuild": "^0.28.1", - "mocha": "^11.3.0", - "typescript": "^6.0.3" + "esbuild": "^0.28.2", + "mocha": "^11.8.0", + "typescript": "^7.0.2" } } diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 0ae2547..f3021b9 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -28,7 +28,10 @@ export function activate(context: vscode.ExtensionContext) { }; const clientOptions: LanguageClientOptions = { - documentSelector: [{ scheme: "file", language: "generals-ini" }], + documentSelector: [ + { scheme: "file", language: "generals-ini" }, + { scheme: "big", language: "generals-ini" }, + ], synchronize: { // Re-index when any .ini in the workspace changes on disk. fileEvents: vscode.workspace.createFileSystemWatcher("**/*.ini"), @@ -39,6 +42,14 @@ export function activate(context: vscode.ExtensionContext) { format: { enable: setting("format.enable", false), }, + preview: { + enable: setting("preview.enable", true), + imageWidth: setting("preview.imageWidth", 160), + zoomPercent: setting("preview.zoomPercent", 100), + }, + progress: { + mode: setting("progress.mode", "indexing"), + }, baseIniRoots: setting("baseIniRoots", []), schemaPath: setting("schema.path", ""), analysis: { @@ -73,6 +84,18 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push( + vscode.commands.registerCommand("zerosyntax.openIndexCacheLocation", async () => { + const cachePath = await client?.sendRequest("zerosyntax/indexCachePath"); + if (!cachePath) { + return; + } + const cacheUri = vscode.Uri.file(cachePath); + if (fs.existsSync(cachePath)) { + await vscode.commands.executeCommand("revealFileInOS", cacheUri); + } else { + vscode.window.showInformationMessage(`ZeroSyntax index cache will be created at ${cachePath}.`); + } + }), vscode.commands.registerCommand("zerosyntax.selectSchema", async () => { const selected = await vscode.window.showOpenDialog({ canSelectMany: false, diff --git a/editors/vscode/src/test/runTest.ts b/editors/vscode/src/test/runTest.ts index d33eff6..a1e3a04 100644 --- a/editors/vscode/src/test/runTest.ts +++ b/editors/vscode/src/test/runTest.ts @@ -3,16 +3,49 @@ import * as os from "os"; import * as path from "path"; import { runTests } from "@vscode/test-electron"; +function writeBig(file: string, entries: Record) { + const names = Object.keys(entries); + let offset = 0x10 + names.reduce((size, name) => size + 8 + Buffer.byteLength(name) + 1, 0); + const header = Buffer.alloc(offset); + header.write("BIGF"); + header.writeUInt32BE(offset + names.reduce((size, name) => size + entries[name].length, 0), 4); + header.writeUInt32BE(names.length, 8); + let cursor = 0x10; + for (const name of names) { + header.writeUInt32BE(offset, cursor); + header.writeUInt32BE(entries[name].length, cursor + 4); + cursor += 8; + cursor += header.write(name, cursor); + header[cursor++] = 0; + offset += entries[name].length; + } + fs.writeFileSync(file, Buffer.concat([header, ...names.map((name) => entries[name])])); +} + async function main() { const extensionDevelopmentPath = path.resolve(__dirname, "../../.."); const extensionTestsPath = path.resolve(__dirname, "suite/index"); const testWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "zerosyntax-vscode-")); const testWorkspaceFile = path.join(testWorkspace, "ZeroSyntax.code-workspace"); + const archive = path.join(testWorkspace, "Base Cache #.big"); + writeBig(archive, { + "Data/INI/Archived.ini": Buffer.from( + "CommandButton SmokeArchivedButton\n" + + " Command = UNIT_BUILD\n" + + "End\n" + + "CommandSet SmokeArchivedSet\n" + + " 1 = SmokeArchivedButton\n" + + "End\n" + ), + }); fs.writeFileSync( testWorkspaceFile, JSON.stringify({ folders: [{ path: "." }], - settings: { "zerosyntax.analysis.allowPercentagesWithoutSign": false }, + settings: { + "zerosyntax.analysis.allowPercentagesWithoutSign": false, + "zerosyntax.baseIniRoots": [archive], + }, }) ); diff --git a/editors/vscode/src/test/suite/smoke.test.ts b/editors/vscode/src/test/suite/smoke.test.ts index 69c83bd..9a61d92 100644 --- a/editors/vscode/src/test/suite/smoke.test.ts +++ b/editors/vscode/src/test/suite/smoke.test.ts @@ -23,7 +23,8 @@ suite("ZeroSyntax VS Code extension", () => { uri, Buffer.from( "Weapon SmokeGun\n ScaleWeaponSpeed = Maybe\n \nEnd\n" + - "Armor SmokeArmor\n Armor = ARMOR_PIERCING 2\nEnd\n" + "Armor SmokeArmor\n Armor = ARMOR_PIERCING 2\nEnd\n" + + "Object SmokeObject\n CommandSet = SmokeArchivedSet\nEnd\n" ) ); @@ -80,6 +81,48 @@ suite("ZeroSyntax VS Code extension", () => { (items) => items.every((diag) => diag.code !== "bad-percent"), "hot-reloaded bare-percentage setting" ); + + const archivedLocations = await waitForAsync( + () => + vscode.commands.executeCommand( + "vscode.executeDefinitionProvider", + uri, + new vscode.Position(8, 20) + ), + (locations) => locations.some((location) => location.uri.scheme === "big"), + "definition into BIG archive" + ); + const archivedLocation = archivedLocations.find( + (location) => location.uri.scheme === "big" + ); + assert.ok(archivedLocation, "expected a BIG archive definition"); + + const archivedDocument = await vscode.workspace.openTextDocument(archivedLocation.uri); + await vscode.window.showTextDocument(archivedDocument); + assert.ok( + archivedDocument.getText().includes("CommandSet SmokeArchivedSet"), + `expected archived source text for ${archivedDocument.uri.toString()}, got ${JSON.stringify( + archivedDocument.getText() + )}` + ); + assert.strictEqual(archivedDocument.languageId, "generals-ini"); + + const nestedLocations = await waitForAsync( + () => + vscode.commands.executeCommand( + "vscode.executeDefinitionProvider", + archivedDocument.uri, + new vscode.Position(4, 12) + ), + (locations) => + locations.some( + (location) => + location.uri.toString() === archivedDocument.uri.toString() && + location.range.start.line === 0 + ), + "definition inside BIG archive" + ); + assert.ok(nestedLocations.length > 0); }); }); @@ -99,3 +142,20 @@ async function waitFor( } assert.fail(`timed out waiting for ${label}`); } + +async function waitForAsync( + get: () => Thenable, + done: (value: T) => boolean, + label: string +): Promise { + const deadline = Date.now() + 15000; + let value = await get(); + while (Date.now() < deadline) { + if (done(value)) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + value = await get(); + } + assert.fail(`timed out waiting for ${label}`); +}