From 84f2c977b545295aaebe52436008d592ec05c23b Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:00:19 +0200 Subject: [PATCH 01/10] perf: prioritize completions by debouncing analysis (#53) --- crates/server/src/backend.rs | 275 +++++++++++++++++++------- crates/server/tests/e2e.py | 75 ++++++- crates/server/tests/typing_latency.py | 200 +++++++++++++++++++ docs/language-server.md | 7 +- editors/vscode/package.json | 7 + editors/vscode/src/extension.ts | 1 + 6 files changed, 485 insertions(+), 80 deletions(-) create mode 100644 crates/server/tests/typing_latency.py diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index 512af7d..604993c 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::time::Duration; use dashmap::DashMap; use ropey::Rope; @@ -46,15 +47,32 @@ struct DocumentState { last_semantic: Option<(u64, Vec)>, } +const DEFAULT_ANALYSIS_DEBOUNCE_MS: u64 = 250; +const MAX_ANALYSIS_DEBOUNCE_MS: u64 = 5_000; + +fn analysis_debounce(options: Option<&serde_json::Value>) -> Duration { + let value = options + .and_then(|v| v.get("analysis")) + .and_then(|v| v.get("debounceMs")); + let millis = value + .and_then(|v| { + v.as_i64() + .map(|n| n.clamp(0, MAX_ANALYSIS_DEBOUNCE_MS as i64) as u64) + .or_else(|| v.as_u64().map(|n| n.min(MAX_ANALYSIS_DEBOUNCE_MS))) + }) + .unwrap_or(DEFAULT_ANALYSIS_DEBOUNCE_MS); + Duration::from_millis(millis) +} + pub struct Backend { client: Client, analyzer: RwLock>, schema_error: Mutex>, /// Open documents, keyed by URI. - docs: DashMap, + docs: Arc>, /// Read-only documents synthesized from configured `.big` archives. virtual_files: DashMap>, - index: RwLock, + index: Arc>, /// Workspace roots, captured at `initialize` and scanned in `initialized`. roots: Mutex>, /// User-configured game/mod INI roots. Entries may be directories or `.big` @@ -84,6 +102,9 @@ pub struct Backend { /// Whether the client supports `window/workDoneProgress` (the scan /// spinner). Captured at `initialize`. progress_support: OnceLock, + /// Delay after the latest edit before whole-document indexes and + /// diagnostics refresh. Parsing and definition-name indexing stay eager. + analysis_debounce: OnceLock, /// Monotonic id source for semantic-token results (delta bookkeeping). semantic_result_id: std::sync::atomic::AtomicU64, } @@ -152,15 +173,107 @@ fn filter_map_ordering_diagnostics( } } +#[derive(Clone, Copy)] +struct RefreshOptions { + enc: PositionEnc, + expected_version: Option, + map_ordering_diagnostics_enabled: bool, +} + +async fn refresh_document( + client: Client, + analyzer: Arc, + docs: Arc>, + index: Arc>, + uri: Url, + options: RefreshOptions, +) { + let Some((rope, parse, version)) = docs.get(&uri).and_then(|d| { + if options + .expected_version + .is_some_and(|expected| expected != d.version) + { + None + } else { + Some((d.rope.clone(), d.parse.clone(), d.version)) + } + }) else { + return; + }; + + let defs = definitions_in(&analyzer, &parse, uri.as_str()); + let refs = references_in(&analyzer, &parse); + let tags = module_tags_in(&analyzer, &parse); + let object_models = object_models_in(&analyzer, &parse); + let object_parents = object_parents_in(&parse); + let str_keys = load_sibling_str_keys(&uri); + + // 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 }; + if entry.version != version { + return; + } + if let Ok(mut idx) = index.write() { + idx.set_file(uri.as_str(), defs); + idx.set_file_refs(uri.as_str(), refs); + idx.set_file_tags(uri.as_str(), tags); + idx.set_file_object_models(uri.as_str(), object_models); + idx.set_file_object_parents(uri.as_str(), object_parents); + idx.set_ini_string_keys(uri.as_str(), str_keys); + } + drop(entry); + + // 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 { + return; + }; + if entry.version != version { + return; + } + let mut cache = std::mem::take(&mut entry.diag_cache); + drop(entry); + + let lsp_diags: Vec = { + let idx = index.read().ok(); + let mut diags = diagnostics::diagnose_with_cache( + &analyzer, + &parse, + idx.as_deref(), + Some(uri.as_str()), + &mut cache, + ); + filter_map_ordering_diagnostics(&mut diags, options.map_ordering_diagnostics_enabled); + diags + .iter() + .map(|d| convert::to_lsp_diagnostic(&rope, d, options.enc)) + .collect() + }; + + let Some(mut entry) = docs.get_mut(&uri) else { + return; + }; + if entry.version != version { + return; + } + entry.diag_cache = cache; + drop(entry); + + client + .publish_diagnostics(uri, lsp_diags, Some(version)) + .await; +} + impl Backend { pub fn new(client: Client) -> Self { Backend { client, analyzer: RwLock::new(Arc::new(Analyzer::embedded())), schema_error: Mutex::new(None), - docs: DashMap::new(), + docs: Arc::new(DashMap::new()), virtual_files: DashMap::new(), - index: RwLock::new(WorkspaceIndex::new()), + index: Arc::new(RwLock::new(WorkspaceIndex::new())), roots: Mutex::new(Vec::new()), encoding: OnceLock::new(), format_enabled: OnceLock::new(), @@ -172,6 +285,7 @@ impl Backend { client_base_ini_hint: OnceLock::new(), snippet_support: OnceLock::new(), progress_support: OnceLock::new(), + analysis_debounce: OnceLock::new(), semantic_result_id: std::sync::atomic::AtomicU64::new(1), } } @@ -203,71 +317,51 @@ impl Backend { /// 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`. - async fn refresh(&self, uri: &Url) { - // Take the cache out so diagnostics run without holding the doc entry - // (avoids lock-order entanglement with the index RwLock). - let Some((rope, parse, version, mut cache)) = self.docs.get_mut(uri).map(|mut d| { - ( - d.rope.clone(), - d.parse.clone(), - d.version, - std::mem::take(&mut d.diag_cache), - ) - }) else { - return; - }; + async fn refresh(&self, uri: &Url, expected_version: Option) { + refresh_document( + self.client.clone(), + self.analyzer(), + self.docs.clone(), + self.index.clone(), + uri.clone(), + RefreshOptions { + enc: self.enc(), + expected_version, + map_ordering_diagnostics_enabled: self.map_ordering_diagnostics_enabled(), + }, + ) + .await; + self.maybe_warn_missing_base_roots(uri).await; + } - // `set_file` bumps the index generation only when definition *names* - // changed, so ordinary keystrokes keep diagnostics caches warm. - // Reference sites never bump it. + fn schedule_refresh(&self, uri: Url, version: i32) { + let client = self.client.clone(); let analyzer = self.analyzer(); - let defs = definitions_in(&analyzer, &parse, uri.as_str()); - let refs = references_in(&analyzer, &parse); - let tags = module_tags_in(&analyzer, &parse); - let object_models = object_models_in(&analyzer, &parse); - let object_parents = object_parents_in(&parse); - let str_keys = load_sibling_str_keys(uri); - if let Ok(mut idx) = self.index.write() { - idx.set_file(uri.as_str(), defs); - idx.set_file_refs(uri.as_str(), refs); - idx.set_file_tags(uri.as_str(), tags); - idx.set_file_object_models(uri.as_str(), object_models); - idx.set_file_object_parents(uri.as_str(), object_parents); - idx.set_ini_string_keys(uri.as_str(), str_keys); - } - + let docs = self.docs.clone(); + let index = self.index.clone(); let enc = self.enc(); - let lsp_diags: Vec = { - let idx = self.index.read().ok(); - let mut diags = diagnostics::diagnose_with_cache( - &analyzer, - &parse, - idx.as_deref(), - Some(uri.as_str()), - &mut cache, - ); - filter_map_ordering_diagnostics(&mut diags, self.map_ordering_diagnostics_enabled()); - diags - .iter() - .map(|d| convert::to_lsp_diagnostic(&rope, d, enc)) - .collect() - }; - - // Hand the warmed cache back unless a newer change superseded us (the - // newer change runs its own refresh against its own parse). - { - let Some(mut entry) = self.docs.get_mut(uri) else { - return; - }; - if entry.version != version { - return; - } - entry.diag_cache = cache; - } - self.client - .publish_diagnostics(uri.clone(), lsp_diags, Some(version)) + let map_ordering_diagnostics_enabled = self.map_ordering_diagnostics_enabled(); + let delay = self + .analysis_debounce + .get() + .copied() + .unwrap_or_else(|| Duration::from_millis(DEFAULT_ANALYSIS_DEBOUNCE_MS)); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + refresh_document( + client, + analyzer, + docs, + index, + uri, + RefreshOptions { + enc, + expected_version: Some(version), + map_ordering_diagnostics_enabled, + }, + ) .await; - self.maybe_warn_missing_base_roots(uri).await; + }); } async fn maybe_warn_missing_base_roots(&self, uri: &Url) { @@ -553,7 +647,7 @@ impl LanguageServer for Backend { // automatically). Shape: // `{ "format": {"enable": bool}, "schemaPath": "schema.json", // "analysis": {"modelMemberStrictness": "compatible", - // "mapOrderingDiagnostics": true}, + // "mapOrderingDiagnostics": true, "debounceMs": 250}, // "baseIniRoots": ["dir-or-big", ...], // "clientBaseIniHint": bool }`. let format_enabled = params @@ -581,6 +675,9 @@ impl LanguageServer for Backend { _ => ModelMemberStrictness::Compatible, }) .unwrap_or_default(); + let _ = self + .analysis_debounce + .set(analysis_debounce(params.initialization_options.as_ref())); if let Ok(mut index) = self.index.write() { index.set_model_member_strictness(model_member_strictness); } @@ -702,7 +799,7 @@ impl LanguageServer for Backend { // still valid — only the index changed. let open: Vec = self.docs.iter().map(|e| e.key().clone()).collect(); for uri in open { - self.refresh(&uri).await; + self.refresh(&uri, None).await; } let (ini, models) = { let idx = self.index.read().ok(); @@ -743,13 +840,14 @@ impl LanguageServer for Backend { last_semantic: None, }, ); - self.refresh(&uri).await; + self.refresh(&uri, Some(version)).await; } async fn did_change(&self, params: DidChangeTextDocumentParams) { let uri = canonical_uri(params.text_document.uri); let version = params.text_document.version; let enc = self.enc(); + let analyzer = self.analyzer(); { let Some(mut entry) = self.docs.get_mut(&uri) else { return; @@ -778,7 +876,7 @@ impl LanguageServer for Backend { convert::apply_change(&mut entry.rope, change.range, &change.text, enc); } entry.text = entry.rope.to_string().into(); - entry.parse = Arc::new(self.analyzer().parse(&entry.text)); + entry.parse = Arc::new(analyzer.parse(&entry.text)); entry.version = version; } else { // Each change applies to the text produced by the previous @@ -798,8 +896,7 @@ impl LanguageServer for Backend { new_len: change.text.len(), }; let (parse, _strategy) = - self.analyzer() - .reparse(&entry.parse, &entry.text, &new_text, edit); + analyzer.reparse(&entry.parse, &entry.text, &new_text, edit); entry.parse = Arc::new(parse); entry.text = new_text; } @@ -807,14 +904,21 @@ impl LanguageServer for Backend { // Full-document replacement. entry.rope = Rope::from_str(&change.text); entry.text = change.text.into(); - entry.parse = Arc::new(self.analyzer().parse(&entry.text)); + entry.parse = Arc::new(analyzer.parse(&entry.text)); } } } entry.version = version; } + // 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. + let defs = definitions_in(&analyzer, &entry.parse, uri.as_str()); + if let Ok(mut idx) = self.index.write() { + idx.set_file(uri.as_str(), defs); + } } - self.refresh(&uri).await; + self.schedule_refresh(uri, version); } async fn did_close(&self, params: DidCloseTextDocumentParams) { @@ -1439,6 +1543,31 @@ mod tests { assert_eq!(diagnostics[0].code, "map-projectile-object"); } + #[test] + fn analysis_debounce_defaults_overrides_and_clamps() { + assert_eq!(analysis_debounce(None), Duration::from_millis(250)); + assert_eq!( + analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 0}}))), + Duration::ZERO + ); + assert_eq!( + analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 400}}))), + Duration::from_millis(400) + ); + assert_eq!( + analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": -1}}))), + Duration::ZERO + ); + assert_eq!( + analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 9000}}))), + Duration::from_millis(5000) + ); + assert_eq!( + analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 12.5}}))), + Duration::from_millis(250) + ); + } + #[test] fn custom_schema_changes_analysis() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index 8e9319e..3a1f2f2 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -21,13 +21,13 @@ def frame(obj: dict) -> bytes: def reader(stream, q: "queue.Queue"): - buf = b"" + buf = bytearray() while True: - chunk = stream.read1(4096) if hasattr(stream, "read1") else stream.read(1) + chunk = stream.read1(65536) if hasattr(stream, "read1") else stream.read(65536) if not chunk: q.put(None) return - buf += chunk + buf.extend(chunk) while True: sep = buf.find(b"\r\n\r\n") if sep == -1: @@ -40,8 +40,8 @@ def reader(stream, q: "queue.Queue"): start = sep + 4 if len(buf) < start + length: break - body = buf[start : start + length] - buf = buf[start + length :] + body = bytes(buf[start : start + length]) + del buf[: start + length] try: q.put(json.loads(body.decode("utf-8"))) except Exception as e: # noqa @@ -97,7 +97,10 @@ def wait_for(pred, what, timeout=15.0): # formatting checks below run, and step 10 verifies the default is off. send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"capabilities": {}, "workspaceFolders": None, "rootUri": root_uri, - "initializationOptions": {"format": {"enable": True}}}}) + "initializationOptions": { + "format": {"enable": True}, + "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"] @@ -201,6 +204,66 @@ def change_doc(doc_uri, version, changes): assert msg, f"no v{version} diagnostics for {doc_uri}" return msg["params"] + # A rapid edit burst must update the parse used by completion immediately, + # while whole-document diagnostics coalesce to the latest version. + burst_uri = "file:///test/burst.ini" + burst_tail = "".join( + f"Weapon Burst{i}\n ScaleWeaponSpeed = Maybe\nEnd\n" for i in range(500) + ) + burst_initial = "Weapon BurstHead\n \nEnd\n" + burst_tail + open_doc(burst_uri, burst_initial) + for version, character in enumerate("Prim", start=2): + column = version + send({"jsonrpc": "2.0", "method": "textDocument/didChange", + "params": {"textDocument": {"uri": burst_uri, "version": version}, + "contentChanges": [{ + "range": { + "start": {"line": 1, "character": column}, + "end": {"line": 1, "character": column}, + }, + "text": character, + }]}}) + send({"jsonrpc": "2.0", "id": 6, "method": "textDocument/completion", + "params": {"textDocument": {"uri": burst_uri}, + "position": {"line": 1, "character": 6}}}) + + published_before_completion = [] + + def completion_or_burst_diag(message): + if (message.get("method") == "textDocument/publishDiagnostics" + and message["params"]["uri"] == burst_uri): + published_before_completion.append(message["params"].get("version")) + return True + return message.get("id") == 6 and "result" in message + + burst_completion = wait_for(completion_or_burst_diag, "burst completion") + assert burst_completion and burst_completion.get("id") == 6, ( + f"diagnostics blocked completion: versions {published_before_completion}" + ) + burst_items = burst_completion["result"] + if isinstance(burst_items, dict): + burst_items = burst_items.get("items", []) + burst_labels = [item["label"] for item in burst_items] + assert "PrimaryDamage" in burst_labels, "completion did not use the latest burst parse" + + burst_versions = [] + + def latest_burst_diag(message): + if (message.get("method") != "textDocument/publishDiagnostics" + or message["params"]["uri"] != burst_uri): + return False + burst_versions.append(message["params"].get("version")) + return message["params"].get("version") == 5 + + burst_diag = wait_for(latest_burst_diag, "latest burst diagnostics") + assert burst_diag, "no diagnostics after burst" + assert burst_versions == [5], f"expected only latest diagnostics, got {burst_versions}" + burst_final = "Weapon BurstHead\n Prim\nEnd\n" + burst_tail + burst_baseline = open_doc("file:///test/burst-baseline.ini", burst_final) + assert norm(burst_diag["params"]["diagnostics"]) == norm(burst_baseline["diagnostics"]), \ + "debounced burst diagnostics differ from a full-text baseline" + print("OK: completion beats debounced diagnostics; burst publishes latest version only") + cases = [ # (name, initial text, [(range, newText)], final text) ("value edit + field insert (multi-change batch)", diff --git a/crates/server/tests/typing_latency.py b/crates/server/tests/typing_latency.py new file mode 100644 index 0000000..78cddaa --- /dev/null +++ b/crates/server/tests/typing_latency.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Measure typing responsiveness through the real LSP binary. + +Usage: python typing_latency.py +""" + +import json +import pathlib +import queue +import statistics +import subprocess +import sys +import threading +import time + +sys.dont_write_bytecode = True +from e2e import frame, reader + + +def main() -> int: + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + return 2 + + exe, filename = sys.argv[1:] + path = pathlib.Path(filename).resolve() + text = path.read_text(encoding="utf-8", errors="replace") + lines = text.splitlines() + uri = path.as_uri() + proc = subprocess.Popen( + [exe], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=0, + ) + messages: "queue.Queue" = queue.Queue() + threading.Thread(target=reader, args=(proc.stdout, messages), daemon=True).start() + diagnostics = [] + + def send(message): + proc.stdin.write(frame(message)) + proc.stdin.flush() + + def receive(timeout=30.0): + message = messages.get(timeout=timeout) + if message is None: + raise RuntimeError("language server exited") + if message.get("method") == "textDocument/publishDiagnostics": + params = message["params"] + diagnostics.append( + (time.perf_counter(), params.get("version"), len(params["diagnostics"])) + ) + return message + + def wait_id(request_id): + try: + while True: + message = receive() + if message.get("id") == request_id: + return message + except queue.Empty as error: + raise RuntimeError(f"timed out waiting for response {request_id}") from error + + def wait_diagnostics(version): + try: + while True: + match = next((item for item in diagnostics if item[1] == version), None) + if match: + return match + receive() + except queue.Empty as error: + seen = [item[1] for item in diagnostics] + raise RuntimeError( + f"timed out waiting for diagnostics {version}; saw {seen}" + ) from error + + send({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "processId": None, + "rootUri": None, + "capabilities": {"general": {"positionEncodings": ["utf-8"]}}, + }, + }) + wait_id(1) + send({"jsonrpc": "2.0", "method": "initialized", "params": {}}) + opened = time.perf_counter() + send({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": uri, + "languageId": "generals-ini", + "version": 1, + "text": text, + } + }, + }) + first_diag = wait_diagnostics(1) + + line_number, line = next( + (i, line) + for i, line in enumerate(lines) + if "=" in line and not line.lstrip().startswith(";") and line.split("=", 1)[1].strip() + ) + value_column = line.index("=") + 1 + while line[value_column].isspace(): + value_column += 1 + original = line[value_column] + replacement = "X" if original != "X" else "Y" + position = {"line": line_number, "character": value_column} + end = {"line": line_number, "character": value_column + 1} + completion_position = {"line": line_number, "character": len(line)} + version = 1 + request_id = 10 + + def change(character): + nonlocal version + version += 1 + send({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": { + "textDocument": {"uri": uri, "version": version}, + "contentChanges": [{ + "range": {"start": position, "end": end}, + "text": character, + }], + }, + }) + + def complete(): + nonlocal request_id + request_id += 1 + send({ + "jsonrpc": "2.0", + "id": request_id, + "method": "textDocument/completion", + "params": {"textDocument": {"uri": uri}, "position": completion_position}, + }) + wait_id(request_id) + + idle = [] + for _ in range(20): + started = time.perf_counter() + complete() + idle.append((time.perf_counter() - started) * 1000) + + results = [] + current = original + for burst in (1, 4, 8, 16): + started = time.perf_counter() + for _ in range(burst): + current = replacement if current == original else original + change(current) + latest = version + complete() + completion_ms = (time.perf_counter() - started) * 1000 + latest_diag = wait_diagnostics(latest) + results.append({ + "edits": burst, + "completion_ms": round(completion_ms, 1), + "diagnostics_ms": round((latest_diag[0] - started) * 1000, 1), + "published_versions": [ + item[1] for item in diagnostics if started <= item[0] <= latest_diag[0] + ], + }) + + report = { + "file": str(path), + "file_mib": round(len(text.encode("utf-8")) / 1024 / 1024, 2), + "lines": len(lines), + "diagnostics": first_diag[2], + "open_to_diagnostics_ms": round((first_diag[0] - opened) * 1000, 1), + "idle_completion_median_ms": round(statistics.median(idle), 2), + "idle_completion_p95_ms": round(sorted(idle)[-2], 2), + "bursts": results, + } + print(json.dumps(report, indent=2)) + + send({"jsonrpc": "2.0", "id": 99, "method": "shutdown", "params": None}) + wait_id(99) + send({"jsonrpc": "2.0", "method": "exit", "params": None}) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (AssertionError, RuntimeError, queue.Empty) as error: + print(f"typing benchmark failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/docs/language-server.md b/docs/language-server.md index 8322469..f3ce47b 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -87,7 +87,8 @@ symbols. "schemaPath": "C:/Mods/MyMod/schema.json", "analysis": { "modelMemberStrictness": "compatible", - "mapOrderingDiagnostics": true + "mapOrderingDiagnostics": true, + "debounceMs": 250 }, "baseIniRoots": [ "C:/Games/Zero Hour", @@ -106,6 +107,10 @@ symbols. defaults to `compatible`. - `analysis.mapOrderingDiagnostics` controls source-backed forward-order warnings in `map.ini` and `solo.ini`. It defaults to `true`. +- `analysis.debounceMs` waits this many milliseconds after the latest edit + before refreshing whole-document indexes and diagnostics. Parsing and + completions remain immediate. It defaults to `250`; valid values are + `0`–`5000`, where `0` refreshes as soon as possible. - `baseIniRoots` accepts directories and `.big` archives containing base game or mod INI files and W3D assets. Those INI definitions are treated as loaded before `map.ini` and `solo.ini`. diff --git a/editors/vscode/package.json b/editors/vscode/package.json index d4bfb35..a57b3e1 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -86,6 +86,13 @@ "default": true, "markdownDescription": "Warn when map.ini or solo.ini uses a definition before an engine parser resolves it. Changing this restarts the language server." }, + "zerosyntax.analysis.debounceMs": { + "type": "integer", + "default": 250, + "minimum": 0, + "maximum": 5000, + "markdownDescription": "Milliseconds to wait after typing before refreshing whole-document indexes and diagnostics. Parsing and completions remain immediate. Use `0` to refresh as soon as possible. Changing this restarts the language server." + }, "zerosyntax.trace.server": { "type": "string", "enum": [ diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index d717d3d..ccff5f3 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -43,6 +43,7 @@ export function activate(context: vscode.ExtensionContext) { analysis: { modelMemberStrictness: setting("analysis.modelMemberStrictness", "compatible"), mapOrderingDiagnostics: setting("analysis.mapOrderingDiagnostics", true), + debounceMs: setting("analysis.debounceMs", 250), }, clientBaseIniHint: true, }), From 9fb75fa9d0d1a9bde84b1e6c04fd149e2e1ef4a9 Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:09:40 +0200 Subject: [PATCH 02/10] perf: update extension to use esbuild for improved compile + smaller .vsix (#52) --- editors/vscode/.vscodeignore | 8 +- editors/vscode/package-lock.json | 485 +++++++++++++++++++++++++++++++ editors/vscode/package.json | 5 +- 3 files changed, 492 insertions(+), 6 deletions(-) diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore index d2ea7a7..5bbae2d 100644 --- a/editors/vscode/.vscodeignore +++ b/editors/vscode/.vscodeignore @@ -1,12 +1,12 @@ -# Shipped in the .vsix: out/ (compiled extension), server/ (bundled binary, +# Shipped in the .vsix: out/ (bundled extension), server/ (bundled binary, # placed by the release workflow), syntaxes/, language-configuration.json, -# README, LICENSE, and production node_modules (vscode-languageclient). +# README, LICENSE, and the icon. .vscode/** +.vscode-test/** src/** out-test/** tsconfig.json tsconfig.test.json **/*.map **/*.ts -node_modules/@types/** -node_modules/typescript/** +node_modules/** diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 1040835..f62869a 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -17,6 +17,7 @@ "@types/vscode": "^1.84.0", "@vscode/test-electron": "^3.0.0", "@vscode/vsce": "^3.9.2", + "esbuild": "^0.28.1", "mocha": "^11.3.0", "typescript": "^6.0.3" }, @@ -234,6 +235,448 @@ "node": ">=6.9.0" } }, + "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==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1829,6 +2272,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "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" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index a57b3e1..7c4104b 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -107,9 +107,9 @@ } }, "scripts": { - "compile": "tsc -p ./", + "compile": "tsc --noEmit && esbuild src/extension.ts --bundle --minify --legal-comments=linked --platform=node --external:vscode --outfile=out/extension.js", "compile:test": "tsc -p ./tsconfig.test.json", - "watch": "tsc -watch -p ./", + "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", "package": "vsce package --allow-missing-repository", "test": "npm run compile && npm run compile:test && node ./out-test/src/test/runTest.js" @@ -123,6 +123,7 @@ "@types/vscode": "^1.84.0", "@vscode/test-electron": "^3.0.0", "@vscode/vsce": "^3.9.2", + "esbuild": "^0.28.1", "mocha": "^11.3.0", "typescript": "^6.0.3" } From 4e128fa4a32de2e889f7802801f4a817c6c73a01 Mon Sep 17 00:00:00 2001 From: ViTeXFTW Date: Sat, 18 Jul 2026 14:11:02 +0200 Subject: [PATCH 03/10] chore: bump patch version --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 196b478..11ac00c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "zerosyntax-analysis" -version = "1.2.0" +version = "1.2.1" dependencies = [ "criterion", "rowan", @@ -1540,7 +1540,7 @@ dependencies = [ [[package]] name = "zerosyntax-schema" -version = "1.2.0" +version = "1.2.1" dependencies = [ "serde", "serde_json", @@ -1548,7 +1548,7 @@ dependencies = [ [[package]] name = "zerosyntax-server" -version = "1.2.0" +version = "1.2.1" dependencies = [ "anyhow", "clap", @@ -1568,7 +1568,7 @@ dependencies = [ [[package]] name = "zerosyntax-syntax" -version = "1.2.0" +version = "1.2.1" dependencies = [ "criterion", "logos", diff --git a/Cargo.toml b/Cargo.toml index e36a757..7a072b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/schema", "crates/syntax", "crates/analysis", "crates/server"] [workspace.package] -version = "1.2.0" +version = "1.2.1" edition = "2021" license = "MIT" repository = "https://github.com/ViTeXFTW/ZeroSyntaxV2" From 1caf6f3d6ac9dbdf86a46d493311f31f57bd8070 Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:03:30 +0200 Subject: [PATCH 04/10] feat: add ParticleSystem value formats (#54) --- crates/analysis/src/completion.rs | 19 ++ crates/analysis/src/diagnostics.rs | 47 +++++ crates/analysis/src/semantic.rs | 27 +++ .../tests/spec/ParticleSystemValues.ini | 7 + .../tests/spec/ParticleSystemValues.spec.toml | 27 +++ crates/schema/schema.json | 193 ++++++++++-------- crates/schema/src/lib.rs | 33 ++- 7 files changed, 260 insertions(+), 93 deletions(-) create mode 100644 crates/analysis/tests/spec/ParticleSystemValues.ini create mode 100644 crates/analysis/tests/spec/ParticleSystemValues.spec.toml diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index 45962e4..f24bf12 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -502,6 +502,9 @@ fn type_snippet_placeholder(ty: &ValueType, n: usize) -> String { fn value_snippet(ty: &ValueType) -> Option { match ty { + ValueType::RandomVariable { .. } => Some("${1:0} ${2:0}$0".into()), + ValueType::RandomKeyframe => Some("${1:0} ${2:0} ${3:0}$0".into()), + ValueType::ColorKeyframe => Some("R:${1:0} G:${2:0} B:${3:0} ${4:0}$0".into()), ValueType::TokenList { tokens } if tokens.len() > 1 => { let mut snippet = tokens .iter() @@ -526,6 +529,19 @@ fn completions_for_type( index: Option<&WorkspaceIndex>, ) -> Vec { match ty { + ValueType::RandomVariable { value_set } if value_index == 2 => completions_for_type( + analyzer, + &ValueType::Enum { + value_set: value_set.clone(), + }, + 0, + current_token, + first_token, + index, + ), + ValueType::RandomVariable { .. } | ValueType::RandomKeyframe | ValueType::ColorKeyframe => { + Vec::new() + } ValueType::OneOf { variants } => { if current_token.is_some() || value_index > 0 { return ty @@ -776,6 +792,9 @@ fn type_label(ty: &ValueType) -> String { ValueType::Reference { ref_kind } => format!("ref {ref_kind:?}"), ValueType::W3dModel => "w3d model".into(), ValueType::W3dModelMember => "w3d model member".into(), + ValueType::RandomVariable { .. } => "real real [distribution]".into(), + ValueType::RandomKeyframe => "real real frame".into(), + ValueType::ColorKeyframe => "R: G: B: frame".into(), ValueType::Prefixed { prefix, value_type } => { format!("{prefix}:{}", type_label(value_type)) } diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index 3cdf4b1..00fa676 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -1326,6 +1326,50 @@ impl<'a> Ctx<'a> { self.check_reference(*ref_kind, tok); } } + ValueType::RandomVariable { value_set } => { + for tok in tokens.iter().take(2) { + self.check_number(tok, NumKind::Real); + } + if tokens.len() < 2 { + if let Some(key) = field.key() { + self.warning( + &key, + "missing-value", + format!("`{}` expects at least 2 values", key.text()), + ); + } + } + if let Some(distribution) = tokens.get(2) { + self.check_enum_member(value_set, distribution); + } + } + ValueType::RandomKeyframe => { + for tok in tokens.iter().take(2) { + self.check_number(tok, NumKind::Real); + } + if let Some(frame) = tokens.get(2) { + self.check_number(frame, NumKind::UInt); + } else if let Some(key) = field.key() { + self.warning( + &key, + "missing-value", + format!("`{}` expects 3 values", key.text()), + ); + } + } + ValueType::ColorKeyframe => { + if tokens.len() >= 4 { + let (color, frame) = tokens.split_at(tokens.len() - 1); + self.check_axes(field, color, &["R", "G", "B"], None, true); + self.check_number(&frame[0], NumKind::UInt); + } else if let Some(key) = field.key() { + self.warning( + &key, + "missing-value", + format!("`{}` expects a color and frame", key.text()), + ); + } + } // A fixed sequence of typed tokens; each listed token is required // (the engine's parse function calls getNextToken for each). ValueType::TokenList { tokens: specs } => { @@ -1459,6 +1503,9 @@ impl<'a> Ctx<'a> { | ValueType::Color | ValueType::Coord2D | ValueType::Coord3D + | ValueType::RandomVariable { .. } + | ValueType::RandomKeyframe + | ValueType::ColorKeyframe | ValueType::TokenList { .. } | ValueType::OneOf { .. } | ValueType::Unknown { .. } => {} diff --git a/crates/analysis/src/semantic.rs b/crates/analysis/src/semantic.rs index b352f72..ca56582 100644 --- a/crates/analysis/src/semantic.rs +++ b/crates/analysis/src/semantic.rs @@ -187,6 +187,17 @@ impl<'a> Sem<'a> { .map(|token| token.text().trim_matches('"')) .collect::>(); for (i, tok) in value_tokens.iter().enumerate() { + if matches!(active_ty, Some(ValueType::RandomVariable { .. })) { + self.set( + tok, + if i == 2 { + SemKind::EnumMember + } else { + SemKind::Number + }, + ); + continue; + } // Token lists classify each position by its own element type. let elem = active_ty.and_then(|ty| ty.token_type_at_input(&input, i)); self.set(tok, value_token_kind(tok, elem)); @@ -230,6 +241,9 @@ fn value_token_kind(tok: &SyntaxToken, ty: Option<&ValueType>) -> SemKind { | Some(ValueType::Velocity) | Some(ValueType::Acceleration) | Some(ValueType::Color) + | Some(ValueType::RandomVariable { .. }) + | Some(ValueType::RandomKeyframe) + | Some(ValueType::ColorKeyframe) | Some(ValueType::Coord2D) | Some(ValueType::Coord3D) => SemKind::Number, Some(ValueType::Reference { .. }) @@ -281,6 +295,19 @@ mod tests { assert!(t.iter().any(|(k, s)| *k == SemKind::Number && s == "100")); } + #[test] + fn classifies_particle_keyframe_values_as_numbers() { + let src = "ParticleSystem Test\n Alpha1 = 0 1 2\n Color1 = R:0 G:1 B:2 3\nEnd\n"; + let t = toks(src); + for value in ["0", "1", "2", "R:0", "G:1", "B:2", "3"] { + assert!( + t.iter() + .any(|(kind, text)| *kind == SemKind::Number && text == value), + "{value} was not classified as a number" + ); + } + } + #[test] fn range_tokens_cover_exactly_the_intersecting_blocks() { let a = Analyzer::embedded(); diff --git a/crates/analysis/tests/spec/ParticleSystemValues.ini b/crates/analysis/tests/spec/ParticleSystemValues.ini new file mode 100644 index 0000000..e29c04f --- /dev/null +++ b/crates/analysis/tests/spec/ParticleSystemValues.ini @@ -0,0 +1,7 @@ +ParticleSystem ParticleSystemValues + AngleZ = 0 1 $1 + VelocityDamping = 0 1 invalid + AngularDamping = invalid 1 + Alpha1 = 0 1 invalid + Color1 = R:0 G:0 B:0 invalid +End diff --git a/crates/analysis/tests/spec/ParticleSystemValues.spec.toml b/crates/analysis/tests/spec/ParticleSystemValues.spec.toml new file mode 100644 index 0000000..926619d --- /dev/null +++ b/crates/analysis/tests/spec/ParticleSystemValues.spec.toml @@ -0,0 +1,27 @@ +[[complete]] +at = "$1" +includes = ["CONSTANT", "UNIFORM", "GAUSSIAN", "TRIANGULAR", "LOW_BIAS", "HIGH_BIAS"] + +[[diag]] +severity = "error" +code = "bad-enum" +on = "invalid" +nth = 1 + +[[diag]] +severity = "error" +code = "bad-number" +on = "invalid" +nth = 2 + +[[diag]] +severity = "error" +code = "bad-number" +on = "invalid" +nth = 3 + +[[diag]] +severity = "error" +code = "bad-number" +on = "invalid" +nth = 4 diff --git a/crates/schema/schema.json b/crates/schema/schema.json index 6d4fe35..ea21d9f 100644 --- a/crates/schema/schema.json +++ b/crates/schema/schema.json @@ -5732,24 +5732,24 @@ { "name": "Radius", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable" }, { "name": "Height", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable" }, { "name": "InitialDelay", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable" }, @@ -13886,8 +13886,8 @@ { "name": "AngleX", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": "only compiled with PARTICLE_USE_XY_ROTATION" @@ -13895,8 +13895,8 @@ { "name": "AngleY", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": "only compiled with PARTICLE_USE_XY_ROTATION" @@ -13904,8 +13904,8 @@ { "name": "AngleZ", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -13913,8 +13913,8 @@ { "name": "AngularRateX", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": "only compiled with PARTICLE_USE_XY_ROTATION" @@ -13922,8 +13922,8 @@ { "name": "AngularRateY", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": "only compiled with PARTICLE_USE_XY_ROTATION" @@ -13931,8 +13931,8 @@ { "name": "AngularRateZ", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -13940,8 +13940,8 @@ { "name": "AngularDamping", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -13949,8 +13949,8 @@ { "name": "VelocityDamping", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -13992,8 +13992,8 @@ { "name": "Lifetime", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14009,8 +14009,8 @@ { "name": "Size", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14018,8 +14018,8 @@ { "name": "StartSizeRate", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14027,8 +14027,8 @@ { "name": "SizeRate", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14036,8 +14036,8 @@ { "name": "SizeRateDamping", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14045,8 +14045,7 @@ { "name": "Alpha1", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe" + "kind": "random_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe", "doc": " " @@ -14054,8 +14053,7 @@ { "name": "Alpha2", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe" + "kind": "random_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe", "doc": " " @@ -14063,8 +14061,7 @@ { "name": "Alpha3", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe" + "kind": "random_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe", "doc": " " @@ -14072,8 +14069,7 @@ { "name": "Alpha4", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe" + "kind": "random_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe", "doc": " " @@ -14081,8 +14077,7 @@ { "name": "Alpha5", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe" + "kind": "random_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe", "doc": " " @@ -14090,8 +14085,7 @@ { "name": "Alpha6", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe" + "kind": "random_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe", "doc": " " @@ -14099,8 +14093,7 @@ { "name": "Alpha7", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe" + "kind": "random_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe", "doc": " " @@ -14108,8 +14101,7 @@ { "name": "Alpha8", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe" + "kind": "random_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRandomKeyframe", "doc": " " @@ -14117,8 +14109,7 @@ { "name": "Color1", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe" + "kind": "color_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe", "doc": "R:r G:g B:b " @@ -14126,8 +14117,7 @@ { "name": "Color2", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe" + "kind": "color_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe", "doc": "R:r G:g B:b " @@ -14135,8 +14125,7 @@ { "name": "Color3", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe" + "kind": "color_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe", "doc": "R:r G:g B:b " @@ -14144,8 +14133,7 @@ { "name": "Color4", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe" + "kind": "color_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe", "doc": "R:r G:g B:b " @@ -14153,8 +14141,7 @@ { "name": "Color5", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe" + "kind": "color_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe", "doc": "R:r G:g B:b " @@ -14162,8 +14149,7 @@ { "name": "Color6", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe" + "kind": "color_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe", "doc": "R:r G:g B:b " @@ -14171,8 +14157,7 @@ { "name": "Color7", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe" + "kind": "color_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe", "doc": "R:r G:g B:b " @@ -14180,8 +14165,7 @@ { "name": "Color8", "value_type": { - "kind": "unknown", - "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe" + "kind": "color_keyframe" }, "parse_fn": "ParticleSystemTemplate::parseRGBColorKeyframe", "doc": "R:r G:g B:b " @@ -14189,8 +14173,8 @@ { "name": "ColorScale", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14198,8 +14182,8 @@ { "name": "BurstDelay", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14207,8 +14191,8 @@ { "name": "BurstCount", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14216,8 +14200,8 @@ { "name": "InitialDelay", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14242,8 +14226,8 @@ { "name": "VelOrthoX", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14251,8 +14235,8 @@ { "name": "VelOrthoY", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14260,8 +14244,8 @@ { "name": "VelOrthoZ", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14269,8 +14253,8 @@ { "name": "VelSpherical", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14278,8 +14262,8 @@ { "name": "VelHemispherical", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14287,8 +14271,8 @@ { "name": "VelCylindricalRadial", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14296,8 +14280,8 @@ { "name": "VelCylindricalNormal", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14305,8 +14289,8 @@ { "name": "VelOutward", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -14314,8 +14298,8 @@ { "name": "VelOutwardOther", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseGameClientRandomVariable" + "kind": "random_variable", + "value_set": "random_distribution" }, "parse_fn": "INI::parseGameClientRandomVariable", "doc": null @@ -68868,6 +68852,35 @@ } ] }, + { + "id": "random_distribution", + "members": [ + { + "name": "CONSTANT", + "value": 0 + }, + { + "name": "UNIFORM", + "value": 1 + }, + { + "name": "GAUSSIAN", + "value": 2 + }, + { + "name": "TRIANGULAR", + "value": 3 + }, + { + "name": "LOW_BIAS", + "value": 4 + }, + { + "name": "HIGH_BIAS", + "value": 5 + } + ] + }, { "id": "particle_priority", "members": [ diff --git a/crates/schema/src/lib.rs b/crates/schema/src/lib.rs index 1b103c1..e8c85cd 100644 --- a/crates/schema/src/lib.rs +++ b/crates/schema/src/lib.rs @@ -213,6 +213,12 @@ pub enum ValueType { Coord2D, /// `X:x Y:y Z:z` coordinate. Coord3D, + /// Two real bounds followed by an optional distribution name. + RandomVariable { value_set: String }, + /// Two real bounds followed by an unsigned frame number. + RandomKeyframe, + /// `R:r G:g B:b` followed by an unsigned frame number. + ColorKeyframe, /// One name drawn from a value set (an enum). Enum { value_set: String }, /// One or more flag names from a value set, with optional `+`/`-` modifiers @@ -309,7 +315,11 @@ impl ValueType { }; } return match active { - ValueType::BitFlags { .. } | ValueType::ReferenceList { .. } => Some(active), + ValueType::BitFlags { .. } + | ValueType::ReferenceList { .. } + | ValueType::RandomVariable { .. } + | ValueType::RandomKeyframe + | ValueType::ColorKeyframe => Some(active), _ if index == 0 => Some(active), _ => None, }; @@ -346,7 +356,12 @@ impl ValueType { return active.token_index_at_input(input, index); } let ValueType::TokenList { tokens } = active else { - return Some(0); + return match active { + ValueType::RandomVariable { .. } + | ValueType::RandomKeyframe + | ValueType::ColorKeyframe => Some(index), + _ => Some(0), + }; }; let mut raw = 0; for (logical, ty) in tokens.iter().enumerate() { @@ -551,6 +566,16 @@ mod tests { )); } + #[test] + fn structured_particle_values_type_every_raw_token() { + for (ty, input) in [ + (ValueType::RandomKeyframe, vec!["0", "1", "2"]), + (ValueType::ColorKeyframe, vec!["R:0", "G:0", "B:0", "2"]), + ] { + assert!((0..input.len()).all(|index| ty.token_type_at_input(&input, index).is_some())); + } + } + fn contains(ty: &ValueType, predicate: fn(&ValueType) -> bool) -> bool { predicate(ty) || match ty { @@ -694,7 +719,9 @@ mod tests { definitions: &HashSet, ) { match value_type { - ValueType::Enum { value_set } | ValueType::BitFlags { value_set } => assert!( + ValueType::Enum { value_set } + | ValueType::BitFlags { value_set } + | ValueType::RandomVariable { value_set } => assert!( value_sets.contains(value_set.as_str()), "{path} uses missing value set `{value_set}`" ), From 21415e2a4c9049b3abfe7337bd7ebc644484f3ba Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:44:46 +0200 Subject: [PATCH 05/10] feat: validate Weapon field values (#55) --- crates/analysis/tests/spec/WeaponValues.ini | 31 ++++ .../tests/spec/WeaponValues.spec.toml | 44 +++++ crates/schema/schema.json | 152 ++++++++++++++---- crates/schema/src/lib.rs | 19 +++ 4 files changed, 218 insertions(+), 28 deletions(-) create mode 100644 crates/analysis/tests/spec/WeaponValues.ini create mode 100644 crates/analysis/tests/spec/WeaponValues.spec.toml diff --git a/crates/analysis/tests/spec/WeaponValues.ini b/crates/analysis/tests/spec/WeaponValues.ini new file mode 100644 index 0000000..843b4b0 --- /dev/null +++ b/crates/analysis/tests/spec/WeaponValues.ini @@ -0,0 +1,31 @@ +Object LaserObject +End + +Weapon ValidWeapon + LaserName = LaserObject + LaserBoneName = WeaponA + DamageStatusType = BURNED + RadiusDamageAffects = SELF ALLIES NOT_AIRBORNE + ProjectileCollidesWith = ALLIES PROJECTILES CONTROLLED_STRUCTURES + AntiAirborneVehicle = Yes + AntiGround = Yes + AntiProjectile = No + AntiSmallMissile = No + AntiMine = No + AntiParachute = No + AntiAirborneInfantry = Yes + AntiBallisticMissile = No + DelayBetweenShots = 500 + DelayBetweenShots = Min:100 Max:200 + WeaponBonus = GARRISONED DAMAGE 125% +End + +Weapon InvalidWeapon + LaserName = MissingLaser + DamageStatusType = CRANKY + RadiusDamageAffects = FRIENDS + ProjectileCollidesWith = CIVILIANS + AntiAirborneVehicle = Maybe + DelayBetweenShots = eventually + WeaponBonus = SOMETIMES POWER lots +End diff --git a/crates/analysis/tests/spec/WeaponValues.spec.toml b/crates/analysis/tests/spec/WeaponValues.spec.toml new file mode 100644 index 0000000..c0bdd93 --- /dev/null +++ b/crates/analysis/tests/spec/WeaponValues.spec.toml @@ -0,0 +1,44 @@ +[[diag]] +severity = "warning" +code = "unresolved-reference" +on = "MissingLaser" + +[[diag]] +severity = "error" +code = "bad-enum" +on = "CRANKY" + +[[diag]] +severity = "error" +code = "bad-flag" +on = "FRIENDS" + +[[diag]] +severity = "error" +code = "bad-flag" +on = "CIVILIANS" + +[[diag]] +severity = "error" +code = "bad-bool" +on = "Maybe" + +[[diag]] +severity = "error" +code = "bad-number" +on = "eventually" + +[[diag]] +severity = "error" +code = "bad-enum" +on = "SOMETIMES" + +[[diag]] +severity = "error" +code = "bad-enum" +on = "POWER" + +[[diag]] +severity = "error" +code = "bad-percent" +on = "lots" diff --git a/crates/schema/schema.json b/crates/schema/schema.json index ea21d9f..bdd8d68 100644 --- a/crates/schema/schema.json +++ b/crates/schema/schema.json @@ -16005,7 +16005,8 @@ { "name": "LaserName", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "object" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -16016,7 +16017,7 @@ "kind": "ascii_string" }, "parse_fn": "INI::parseAsciiString", - "doc": null + "doc": "Bone on the firing object's model; a shared Weapon template does not identify that object statically." }, { "name": "LeechRangeWeapon", @@ -16134,8 +16135,8 @@ { "name": "DamageStatusType", "value_type": { - "kind": "unknown", - "parse_fn": "ObjectStatusMaskType::parseSingleBitFromINI" + "kind": "enum", + "value_set": "object_status" }, "parse_fn": "ObjectStatusMaskType::parseSingleBitFromINI", "doc": null @@ -16278,8 +16279,31 @@ { "name": "DelayBetweenShots", "value_type": { - "kind": "unknown", - "parse_fn": "WeaponTemplate::parseShotDelay" + "kind": "one_of", + "variants": [ + { + "kind": "duration" + }, + { + "kind": "token_list", + "tokens": [ + { + "kind": "prefixed", + "prefix": "Min", + "value_type": { + "kind": "duration" + } + }, + { + "kind": "prefixed", + "prefix": "Max", + "value_type": { + "kind": "duration" + } + } + ] + } + ] }, "parse_fn": "WeaponTemplate::parseShotDelay", "doc": null @@ -16287,8 +16311,8 @@ { "name": "RadiusDamageAffects", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitString32" + "kind": "bit_flags", + "value_set": "weapon_affects" }, "parse_fn": "INI::parseBitString32", "doc": null @@ -16296,8 +16320,8 @@ { "name": "ProjectileCollidesWith", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitString32" + "kind": "bit_flags", + "value_set": "weapon_collide" }, "parse_fn": "INI::parseBitString32", "doc": null @@ -16305,8 +16329,7 @@ { "name": "AntiAirborneVehicle", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitInInt32" + "kind": "bool" }, "parse_fn": "INI::parseBitInInt32", "doc": null @@ -16314,8 +16337,7 @@ { "name": "AntiGround", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitInInt32" + "kind": "bool" }, "parse_fn": "INI::parseBitInInt32", "doc": null @@ -16323,8 +16345,7 @@ { "name": "AntiProjectile", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitInInt32" + "kind": "bool" }, "parse_fn": "INI::parseBitInInt32", "doc": null @@ -16332,8 +16353,7 @@ { "name": "AntiSmallMissile", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitInInt32" + "kind": "bool" }, "parse_fn": "INI::parseBitInInt32", "doc": null @@ -16341,8 +16361,7 @@ { "name": "AntiMine", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitInInt32" + "kind": "bool" }, "parse_fn": "INI::parseBitInInt32", "doc": null @@ -16350,8 +16369,7 @@ { "name": "AntiParachute", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitInInt32" + "kind": "bool" }, "parse_fn": "INI::parseBitInInt32", "doc": null @@ -16359,8 +16377,7 @@ { "name": "AntiAirborneInfantry", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitInInt32" + "kind": "bool" }, "parse_fn": "INI::parseBitInInt32", "doc": null @@ -16368,8 +16385,7 @@ { "name": "AntiBallisticMissile", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseBitInInt32" + "kind": "bool" }, "parse_fn": "INI::parseBitInInt32", "doc": null @@ -16386,8 +16402,20 @@ { "name": "WeaponBonus", "value_type": { - "kind": "unknown", - "parse_fn": "WeaponTemplate::parseWeaponBonusSet" + "kind": "token_list", + "tokens": [ + { + "kind": "enum", + "value_set": "weapon_bonus_condition" + }, + { + "kind": "enum", + "value_set": "weapon_bonus_field" + }, + { + "kind": "percent" + } + ] }, "parse_fn": "WeaponTemplate::parseWeaponBonusSet", "doc": null @@ -67969,6 +67997,32 @@ ], "doc": "TheWeaponBonusNames (Weapon.h)" }, + { + "id": "weapon_bonus_field", + "members": [ + { + "name": "DAMAGE", + "value": 0 + }, + { + "name": "RADIUS", + "value": 1 + }, + { + "name": "RANGE", + "value": 2 + }, + { + "name": "RATE_OF_FIRE", + "value": 3 + }, + { + "name": "PRE_ATTACK", + "value": 4 + } + ], + "doc": "TheWeaponBonusFieldNames (Weapon.h)" + }, { "id": "ocl_create_location", "members": [ @@ -69459,6 +69513,48 @@ } ] }, + { + "id": "weapon_collide", + "members": [ + { + "name": "ALLIES", + "value": 0 + }, + { + "name": "ENEMIES", + "value": 1 + }, + { + "name": "STRUCTURES", + "value": 2 + }, + { + "name": "SHRUBBERY", + "value": 3 + }, + { + "name": "PROJECTILES", + "value": 4 + }, + { + "name": "WALLS", + "value": 5 + }, + { + "name": "SMALL_MISSILES", + "value": 6 + }, + { + "name": "BALLISTIC_MISSILES", + "value": 7 + }, + { + "name": "CONTROLLED_STRUCTURES", + "value": 8 + } + ], + "doc": "TheWeaponCollideMaskNames (Weapon.h)" + }, { "id": "terrain_class", "members": [ diff --git a/crates/schema/src/lib.rs b/crates/schema/src/lib.rs index e8c85cd..c39e1be 100644 --- a/crates/schema/src/lib.rs +++ b/crates/schema/src/lib.rs @@ -827,6 +827,25 @@ mod tests { } } + #[test] + fn weapon_fields_have_concrete_value_types() { + let schema = embedded(); + let weapon = schema + .index() + .block("Weapon") + .expect("Weapon block missing"); + for field in &weapon.fields { + assert!( + !contains(&field.value_type, |ty| matches!( + ty, + ValueType::Unknown { .. } + )), + "Weapon.{} still has an unknown value type", + field.name + ); + } + } + #[test] fn object_backed_module_fields_are_object_references() { let schema = embedded(); From be5693d3fdf941d15a4ae749c197b64c75898567 Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:45:25 +0200 Subject: [PATCH 06/10] feat add OCL object and model reference analysis (#56) --- crates/analysis/src/completion.rs | 26 ++++++++++++- crates/analysis/src/diagnostics.rs | 35 ++++++++++++++++++ crates/analysis/src/index.rs | 15 ++++++-- crates/analysis/src/model.rs | 7 +++- crates/analysis/src/semantic.rs | 1 + crates/analysis/tests/spec/OCLReferences.ini | 22 +++++++++++ .../tests/spec/OCLReferences.spec.toml | 37 +++++++++++++++++++ crates/schema/schema.json | 17 +++++---- crates/schema/src/lib.rs | 2 + 9 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 crates/analysis/tests/spec/OCLReferences.ini create mode 100644 crates/analysis/tests/spec/OCLReferences.spec.toml diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index f24bf12..9123f70 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -494,7 +494,7 @@ fn type_snippet_placeholder(ty: &ValueType, n: usize) -> String { ValueType::AsciiString | ValueType::AsciiStringList | ValueType::QuotedString => { format!("${{{n}:Value}}") } - ValueType::W3dModel => format!("${{{n}:Model}}"), + ValueType::W3dModel | ValueType::W3dModelList => format!("${{{n}:Model}}"), ValueType::W3dModelMember => format!("${{{n}:Bone}}"), _ => format!("${{{n}:?}}"), } @@ -678,7 +678,7 @@ fn completions_for_type( })); out } - ValueType::W3dModel | ValueType::W3dModelMember => Vec::new(), + ValueType::W3dModel | ValueType::W3dModelList | ValueType::W3dModelMember => Vec::new(), _ => Vec::new(), } } @@ -791,6 +791,7 @@ fn type_label(ty: &ValueType) -> String { ValueType::BitFlags { value_set } => format!("flags {value_set}"), ValueType::Reference { ref_kind } => format!("ref {ref_kind:?}"), ValueType::W3dModel => "w3d model".into(), + ValueType::W3dModelList => "w3d models".into(), ValueType::W3dModelMember => "w3d model member".into(), ValueType::RandomVariable { .. } => "real real [distribution]".into(), ValueType::RandomKeyframe => "real real frame".into(), @@ -986,6 +987,27 @@ End assert!(out.contains(&"WeaponA".to_string()), "{out:?}"); } + #[test] + fn ocl_model_list_completes_every_position() { + let a = Analyzer::embedded(); + let mut index = WorkspaceIndex::new(); + index.set_file_models( + "models/Good.w3d", + vec![crate::index::ModelAsset { + name: "Good".into(), + members: vec![], + }], + ); + let src = + "ObjectCreationList Debris\n CreateDebris\n ModelNames = First \n End\nEnd\n"; + let offset = src.find("First ").unwrap() + "First ".len(); + let out = complete(&a, &a.parse(src), offset as u32, Some(&index), None) + .into_iter() + .map(|item| item.label) + .collect::>(); + assert!(out.contains(&"Good".to_string()), "{out:?}"); + } + #[test] fn weapon_bone_completions_use_token_positions() { let a = Analyzer::embedded(); diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index 00fa676..0cc47fb 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -1012,6 +1012,16 @@ impl<'a> Ctx<'a> { } let tokens = field.value_tokens(); match &schema_field.value_type { + ValueType::W3dModelList => { + for tok in &tokens { + self.validate_model_asset_token( + &schema_field.value_type, + tok, + scope_node, + schema_field.model_source.as_ref(), + ); + } + } ValueType::TokenList { tokens: specs } => { let mut i = 0; for spec in specs { @@ -1499,6 +1509,7 @@ impl<'a> Ctx<'a> { | ValueType::QuotedString | ValueType::AsciiStringList | ValueType::W3dModel + | ValueType::W3dModelList | ValueType::W3dModelMember | ValueType::Color | ValueType::Coord2D @@ -2670,6 +2681,30 @@ End ); } + #[test] + fn ocl_object_and_model_lists_validate_every_reference() { + let a = Analyzer::embedded(); + let mut index = WorkspaceIndex::new(); + let objects = a.parse("Object KnownObject\nEnd\n"); + index.set_file( + "objects.ini", + crate::index::definitions_in(&a, &objects, "objects.ini"), + ); + index.set_file_models( + "models/Good.w3d", + vec![crate::index::ModelAsset { + name: "Good".into(), + members: vec![], + }], + ); + let src = "ObjectCreationList Test\n CreateObject\n ObjectNames = KnownObject MissingObject\n End\n CreateDebris\n ModelNames = Good MissingModel\n End\nEnd\n"; + let diags = diagnose(&a, &a.parse(src), Some(&index), Some("ocl.ini")); + assert!(diags.iter().any(|d| d.code == "unresolved-reference" + && &src[d.span.start as usize..d.span.end as usize] == "MissingObject")); + assert!(diags.iter().any(|d| d.code == "unknown-model" + && &src[d.span.start as usize..d.span.end as usize] == "MissingModel")); + } + #[test] fn model_member_strictness_supports_off_compatible_and_strict() { let a = Analyzer::embedded(); diff --git a/crates/analysis/src/index.rs b/crates/analysis/src/index.rs index b516ec5..c12913f 100644 --- a/crates/analysis/src/index.rs +++ b/crates/analysis/src/index.rs @@ -586,10 +586,19 @@ fn collect_object_models(analyzer: &Analyzer, node: &SyntaxNode, out: &mut Vec out.extend( + values + .iter() + .map(|value| value.text().trim_matches('"').to_string()), + ), + ValueType::W3dModel => { + if let Some(value) = values.first() { + out.push(value.text().trim_matches('"').to_string()); + } } + _ => {} } } SyntaxKind::BLOCK | SyntaxKind::MODULE => collect_object_models(analyzer, &child, out), diff --git a/crates/analysis/src/model.rs b/crates/analysis/src/model.rs index 228625f..2750077 100644 --- a/crates/analysis/src/model.rs +++ b/crates/analysis/src/model.rs @@ -144,7 +144,7 @@ pub fn enclosing_scopes<'a>(analyzer: &'a Analyzer, node: &SyntaxNode) -> Vec bool { - matches!(ty, ValueType::W3dModel) + matches!(ty, ValueType::W3dModel | ValueType::W3dModelList) } pub(crate) fn is_model_member_type(ty: &ValueType) -> bool { @@ -253,6 +253,11 @@ fn collect_models(analyzer: &Analyzer, node: &SyntaxNode, out: &mut Vec) .map(|value| value.text().trim_matches('"')) .collect::>(); match &schema_field.value_type { + ValueType::W3dModelList => out.extend( + values + .iter() + .map(|value| value.text().trim_matches('"').to_string()), + ), ValueType::TokenList { .. } | ValueType::OneOf { .. } | ValueType::Prefixed { .. } => { diff --git a/crates/analysis/src/semantic.rs b/crates/analysis/src/semantic.rs index ca56582..52efad7 100644 --- a/crates/analysis/src/semantic.rs +++ b/crates/analysis/src/semantic.rs @@ -249,6 +249,7 @@ fn value_token_kind(tok: &SyntaxToken, ty: Option<&ValueType>) -> SemKind { Some(ValueType::Reference { .. }) | Some(ValueType::ReferenceList { .. }) | Some(ValueType::W3dModel) + | Some(ValueType::W3dModelList) | Some(ValueType::W3dModelMember) => SemKind::Reference, _ => SemKind::StringLit, } diff --git a/crates/analysis/tests/spec/OCLReferences.ini b/crates/analysis/tests/spec/OCLReferences.ini new file mode 100644 index 0000000..3e317ec --- /dev/null +++ b/crates/analysis/tests/spec/OCLReferences.ini @@ -0,0 +1,22 @@ +Object OCLObjectOne +End + +Object OCLObjectTwo +End + +ParticleSystem HulkExplosionTrail +End + +AudioEvent DebrisBigMetal +End + +ObjectCreationList OCL_References + CreateObject + ObjectNames = OCLObjectOne $1NoSuchOCLObject + ParticleSystem = $2NoSuchParticleSystem + End + CreateDebris + ParticleSystem = $3NoSuchDebrisParticleSystem + BounceSound = $4NoSuchBounceSound + End +End diff --git a/crates/analysis/tests/spec/OCLReferences.spec.toml b/crates/analysis/tests/spec/OCLReferences.spec.toml new file mode 100644 index 0000000..cce9267 --- /dev/null +++ b/crates/analysis/tests/spec/OCLReferences.spec.toml @@ -0,0 +1,37 @@ +# ObjectNames is a variadic list of Object references. + +[[diag]] +severity = "warning" +code = "unresolved-reference" +on = "NoSuchOCLObject" + +[[complete]] +at = "$1" +includes = ["OCLObjectOne", "OCLObjectTwo"] + +[[diag]] +severity = "warning" +code = "unresolved-reference" +on = "NoSuchParticleSystem" + +[[diag]] +severity = "warning" +code = "unresolved-reference" +on = "NoSuchDebrisParticleSystem" + +[[diag]] +severity = "warning" +code = "unresolved-reference" +on = "NoSuchBounceSound" + +[[complete]] +at = "$2" +includes = ["HulkExplosionTrail"] + +[[complete]] +at = "$3" +includes = ["HulkExplosionTrail"] + +[[complete]] +at = "$4" +includes = ["DebrisBigMetal"] diff --git a/crates/schema/schema.json b/crates/schema/schema.json index bdd8d68..e9ba736 100644 --- a/crates/schema/schema.json +++ b/crates/schema/schema.json @@ -12544,7 +12544,8 @@ { "name": "ParticleSystem", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "particle_system" }, "parse_fn": "INI::parseAsciiString" }, @@ -12756,8 +12757,8 @@ { "name": "ObjectNames", "value_type": { - "kind": "unknown", - "parse_fn": "parseDebrisObjectNames" + "kind": "reference_list", + "ref_kind": "object" }, "parse_fn": "parseDebrisObjectNames" }, @@ -12825,7 +12826,8 @@ { "name": "ParticleSystem", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "particle_system" }, "parse_fn": "INI::parseAsciiString" }, @@ -13030,8 +13032,7 @@ { "name": "ModelNames", "value_type": { - "kind": "unknown", - "parse_fn": "parseDebrisObjectNames" + "kind": "w3d_model_list" }, "parse_fn": "parseDebrisObjectNames" }, @@ -13084,8 +13085,8 @@ { "name": "BounceSound", "value_type": { - "kind": "unknown", - "parse_fn": "INI::parseAudioEventRTS" + "kind": "reference", + "ref_kind": "audio_event" }, "parse_fn": "INI::parseAudioEventRTS" } diff --git a/crates/schema/src/lib.rs b/crates/schema/src/lib.rs index c39e1be..d143d94 100644 --- a/crates/schema/src/lib.rs +++ b/crates/schema/src/lib.rs @@ -205,6 +205,8 @@ pub enum ValueType { AsciiStringList, /// A W3D model asset name, backed by indexed `.w3d` files. W3dModel, + /// One or more W3D model asset names. + W3dModelList, /// A bone, subobject, mesh, or other member of a W3D model asset. W3dModelMember, /// `R:r G:g B:b [A:a]` color. From 4e8fbbead05df1d76f8ed1ba8e29f721e8a7e6d1 Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:58:43 +0200 Subject: [PATCH 07/10] feat: index audio and texture assets (#57) --- crates/analysis/src/completion.rs | 79 +- crates/analysis/src/diagnostics.rs | 128 +- crates/analysis/src/index.rs | 121 ++ crates/analysis/src/semantic.rs | 7 +- crates/analysis/tests/spec.rs | 20 +- crates/analysis/tests/spec/Assets.ini | 37 + crates/analysis/tests/spec/Assets.spec.toml | 79 + crates/schema/schema.json | 2004 +++++++++++++++++-- crates/schema/src/lib.rs | 66 +- crates/server/src/backend.rs | 138 +- crates/server/src/cli.rs | 9 +- crates/server/src/scan.rs | 65 +- docs/diagnostics.md | 4 +- docs/language-server.md | 12 +- editors/vscode/README.md | 8 +- editors/vscode/package.json | 2 +- 16 files changed, 2591 insertions(+), 188 deletions(-) create mode 100644 crates/analysis/tests/spec/Assets.ini create mode 100644 crates/analysis/tests/spec/Assets.spec.toml diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index 9123f70..120c78d 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -6,10 +6,11 @@ //! * after `=` -> enum/bitflag members, `Yes`/`No`, module names, or (with the //! workspace index) names of the referenced definition kind. -use zerosyntax_schema::ValueType; +use zerosyntax_schema::{AudioExtension, ValueType}; use zerosyntax_syntax::ast::{Block, Field, Module}; use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode}; +use crate::index::AssetKind; use crate::model::{ is_model_asset_type, is_model_member_type, model_member_ini_name, models_for_source, scope_schema, @@ -494,6 +495,10 @@ fn type_snippet_placeholder(ty: &ValueType, n: usize) -> String { ValueType::AsciiString | ValueType::AsciiStringList | ValueType::QuotedString => { format!("${{{n}:Value}}") } + ValueType::AudioFile { .. } => format!("${{{n}:Sound.wav}}"), + ValueType::AudioStemList => format!("${{{n}:Sound}}"), + ValueType::TextureFile => format!("${{{n}:Texture.tga}}"), + ValueType::TextureStem | ValueType::TextureSequenceStem => format!("${{{n}:Texture}}"), ValueType::W3dModel | ValueType::W3dModelList => format!("${{{n}:Model}}"), ValueType::W3dModelMember => format!("${{{n}:Bone}}"), _ => format!("${{{n}:?}}"), @@ -678,11 +683,83 @@ fn completions_for_type( })); out } + ValueType::AudioFile { extension } => asset_completions( + index, + AssetKind::Audio, + "audio file", + |name| match extension { + AudioExtension::Any => Some(name.to_string()), + AudioExtension::Wav if has_extension(name, "wav") => Some(name.to_string()), + AudioExtension::Mp3 if has_extension(name, "mp3") => Some(name.to_string()), + _ => None, + }, + ), + ValueType::AudioStemList => { + asset_completions(index, AssetKind::Audio, "sound stem", |name| { + has_extension(name, "wav").then(|| file_stem(name).to_string()) + }) + } + ValueType::TextureFile => asset_completions(index, AssetKind::Texture, "texture", |name| { + Some(format!("{}.tga", file_stem(name))) + }), + ValueType::TextureStem => asset_completions(index, AssetKind::Texture, "texture", |name| { + Some(file_stem(name).to_string()) + }), + ValueType::TextureSequenceStem => { + asset_completions(index, AssetKind::Texture, "texture", |name| { + let stem = file_stem(name); + if let Some(base) = stem.strip_suffix("0000") { + Some(base.to_string()) + } else if stem + .as_bytes() + .get(stem.len().saturating_sub(4)..) + .is_some_and(|suffix| { + suffix.len() == 4 && suffix.iter().all(u8::is_ascii_digit) + }) + { + None + } else { + Some(stem.to_string()) + } + }) + } ValueType::W3dModel | ValueType::W3dModelList | ValueType::W3dModelMember => Vec::new(), _ => Vec::new(), } } +fn file_stem(name: &str) -> &str { + name.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(name) +} + +fn has_extension(name: &str, extension: &str) -> bool { + name.rsplit_once('.') + .is_some_and(|(_, actual)| actual.eq_ignore_ascii_case(extension)) +} + +fn asset_completions( + index: Option<&WorkspaceIndex>, + kind: AssetKind, + detail: &str, + label: impl Fn(&str) -> Option, +) -> Vec { + let Some(index) = index.filter(|index| index.has_assets(kind)) else { + return Vec::new(); + }; + let mut seen = std::collections::HashSet::new(); + index + .asset_names(kind) + .filter_map(label) + .filter(|label| seen.insert(label.to_ascii_lowercase())) + .map(|label| Completion { + label, + kind: CompletionKind::Reference, + detail: Some(detail.to_string()), + insert: None, + }) + .collect() +} + fn top_level_completions(analyzer: &Analyzer) -> Vec { analyzer .schema() diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index 0cc47fb..f84c222 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -16,11 +16,11 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use zerosyntax_schema::{Field as SchemaField, RefKind, ValueType}; +use zerosyntax_schema::{AudioExtension, Field as SchemaField, RefKind, ValueType}; use zerosyntax_syntax::ast::{Block, Field, Module}; use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode, SyntaxToken}; -use crate::index::ModelMemberStrictness; +use crate::index::{AssetKind, ModelMemberStrictness}; use crate::model::{ is_model_asset_type, is_model_member_type, model_member_matches, models_for_source, module_fits_slot, scope_schema, ScopeSchema, @@ -96,6 +96,8 @@ pub const KNOWN_CODES: &[&str] = &[ "unresolved-reference", "unknown-model", "unknown-model-member", + "unknown-audio-file", + "unknown-texture", "unknown-suppression", "module-wrong-slot", "duplicate-module-tag", @@ -989,6 +991,7 @@ impl<'a> Ctx<'a> { if let Some(schema_field) = scope.field(name) { self.validate_value(&field, &schema_field.value_type); self.validate_model_asset(&field, schema_field, scope_node); + self.validate_raw_asset(&field, &schema_field.value_type); } else if scope.has_field_schema() && !scope.module_slots().iter().any(|s| s.keyword == name) { @@ -1052,6 +1055,63 @@ impl<'a> Ctx<'a> { } } + fn validate_raw_asset(&mut self, field: &Field, ty: &ValueType) { + let Some(index) = self.index else { return }; + let tokens = field.value_tokens(); + match ty { + ValueType::AudioFile { extension } if index.has_assets(AssetKind::Audio) => { + if let Some(token) = tokens.first() { + let name = unquote(token.text()); + let allowed = match extension { + AudioExtension::Any => { + has_extension(name, "wav") || has_extension(name, "mp3") + } + AudioExtension::Wav => has_extension(name, "wav"), + AudioExtension::Mp3 => has_extension(name, "mp3"), + }; + if !name.eq_ignore_ascii_case("None") + && (!allowed || !index.is_asset(AssetKind::Audio, name)) + { + self.warning( + token, + "unknown-audio-file", + format!("`{name}` is not a known audio file"), + ); + } + } + } + ValueType::AudioStemList if index.has_assets(AssetKind::Audio) => { + for token in tokens { + let name = unquote(token.text()); + if !name.eq_ignore_ascii_case("None") + && !index.is_asset(AssetKind::Audio, &format!("{name}.wav")) + { + self.warning( + &token, + "unknown-audio-file", + format!("`{name}` is not a known WAV sound stem"), + ); + } + } + } + ValueType::TextureFile | ValueType::TextureStem | ValueType::TextureSequenceStem + if index.has_assets(AssetKind::Texture) => + { + if let Some(token) = tokens.first() { + let name = unquote(token.text()); + if !name.eq_ignore_ascii_case("None") && !texture_exists(index, ty, name) { + self.warning( + token, + "unknown-texture", + format!("`{name}` is not a known texture"), + ); + } + } + } + _ => {} + } + } + fn validate_model_asset_token( &mut self, ty: &ValueType, @@ -1511,6 +1571,11 @@ impl<'a> Ctx<'a> { | ValueType::W3dModel | ValueType::W3dModelList | ValueType::W3dModelMember + | ValueType::AudioFile { .. } + | ValueType::AudioStemList + | ValueType::TextureFile + | ValueType::TextureStem + | ValueType::TextureSequenceStem | ValueType::Color | ValueType::Coord2D | ValueType::Coord3D @@ -1831,6 +1896,30 @@ impl<'a> Ctx<'a> { } } +fn has_extension(name: &str, extension: &str) -> bool { + name.rsplit_once('.') + .is_some_and(|(_, actual)| actual.eq_ignore_ascii_case(extension)) +} + +fn texture_exists(index: &WorkspaceIndex, ty: &ValueType, name: &str) -> bool { + let exact = |candidate: &str| index.is_asset(AssetKind::Texture, candidate); + match ty { + ValueType::TextureFile if has_extension(name, "dds") => exact(name), + ValueType::TextureFile if has_extension(name, "tga") => { + exact(name) || exact(&format!("{}.dds", &name[..name.len() - 4])) + } + ValueType::TextureFile => false, + ValueType::TextureStem => exact(&format!("{name}.tga")) || exact(&format!("{name}.dds")), + ValueType::TextureSequenceStem => { + exact(&format!("{name}.tga")) + || exact(&format!("{name}.dds")) + || exact(&format!("{name}0000.tga")) + || exact(&format!("{name}0000.dds")) + } + _ => false, + } +} + enum NumKind { Int, UInt, @@ -2737,4 +2826,39 @@ End .iter() .any(|d| d.code == "unknown-model-member")); } + + #[test] + fn raw_asset_warnings_are_gated_per_kind() { + let a = Analyzer::embedded(); + let src = "DialogEvent Dialog\n Filename = Missing.wav\nEnd\nMappedImage Image\n Texture = Missing.tga\nEnd\n"; + let parse = a.parse(src); + let mut index = WorkspaceIndex::new(); + let codes = |index: &WorkspaceIndex| { + diagnose(&a, &parse, Some(index), None) + .into_iter() + .map(|diagnostic| diagnostic.code) + .collect::>() + }; + assert!(!codes(&index) + .iter() + .any(|code| code.starts_with("unknown-"))); + index.set_file_assets( + "audio", + vec![crate::index::FileAsset { + kind: AssetKind::Audio, + name: "Known.wav".into(), + }], + ); + let audio_only = codes(&index); + assert!(audio_only.contains(&"unknown-audio-file")); + assert!(!audio_only.contains(&"unknown-texture")); + index.set_file_assets( + "texture", + vec![crate::index::FileAsset { + kind: AssetKind::Texture, + name: "Known.dds".into(), + }], + ); + assert!(codes(&index).contains(&"unknown-texture")); + } } diff --git a/crates/analysis/src/index.rs b/crates/analysis/src/index.rs index c12913f..4ccaebb 100644 --- a/crates/analysis/src/index.rs +++ b/crates/analysis/src/index.rs @@ -14,6 +14,18 @@ use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode, SyntaxToken}; use crate::model::{scope_schema, ScopeSchema}; use crate::{Analyzer, Span}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AssetKind { + Audio, + Texture, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileAsset { + pub kind: AssetKind, + pub name: String, +} + /// Model data discovered from a W3D asset. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelAsset { @@ -95,6 +107,8 @@ pub struct WorkspaceIndex { model_assets: HashMap>, /// Reverse map for removing/replacing models contributed by one asset file. file_models: HashMap>, + asset_names: HashMap>>, + file_assets: HashMap>, object_models: HashMap)>>, file_object_models: HashMap)>>, object_parents: HashMap>, @@ -194,6 +208,7 @@ impl WorkspaceIndex { self.remove_entries(file); self.remove_site_entries(file); self.remove_model_entries(file); + self.set_file_assets(file, Vec::new()); self.remove_object_model_entries(file); self.remove_object_parent_entries(file); } @@ -265,6 +280,83 @@ impl WorkspaceIndex { } } + /// Replace raw audio/texture assets contributed by a file, directory entry, + /// or synthetic archive contribution. + pub fn set_file_assets(&mut self, file: &str, assets: Vec) { + let affected = self + .file_assets + .get(file) + .into_iter() + .flatten() + .chain(&assets) + .map(|asset| (asset.kind, asset.name.to_ascii_lowercase())) + .collect::>(); + let before = affected + .iter() + .map(|(kind, name)| ((*kind, name.clone()), self.is_asset(*kind, name))) + .collect::>(); + self.remove_asset_entries(file); + for asset in &assets { + self.asset_names + .entry(asset.kind) + .or_default() + .entry(asset.name.to_ascii_lowercase()) + .or_default() + .push((file.to_string(), asset.name.clone())); + } + if assets.is_empty() { + self.file_assets.remove(file); + } else { + self.file_assets.insert(file.to_string(), assets); + } + if before + .into_iter() + .any(|((kind, name), existed)| existed != self.is_asset(kind, &name)) + { + self.generation += 1; + } + } + + fn remove_asset_entries(&mut self, file: &str) { + let Some(assets) = self.file_assets.remove(file) else { + return; + }; + for asset in assets { + let lower = asset.name.to_ascii_lowercase(); + if let Some(names) = self.asset_names.get_mut(&asset.kind) { + if let Some(contribs) = names.get_mut(&lower) { + contribs.retain(|(source, _)| source != file); + if contribs.is_empty() { + names.remove(&lower); + } + } + if names.is_empty() { + self.asset_names.remove(&asset.kind); + } + } + } + } + + pub fn has_assets(&self, kind: AssetKind) -> bool { + self.asset_names + .get(&kind) + .is_some_and(|names| !names.is_empty()) + } + + pub fn is_asset(&self, kind: AssetKind, name: &str) -> bool { + self.asset_names + .get(&kind) + .is_some_and(|names| names.contains_key(&name.to_ascii_lowercase())) + } + + pub fn asset_names(&self, kind: AssetKind) -> impl Iterator { + self.asset_names + .get(&kind) + .into_iter() + .flat_map(|names| names.values().filter_map(|sources| sources.first())) + .map(|(_, display)| display.as_str()) + } + pub fn set_file_object_models(&mut self, file: &str, objects: Vec<(String, Vec)>) { let normalized = normalize_object_models(&objects); let changed = self.file_object_models.get(file) != Some(&normalized); @@ -883,6 +975,35 @@ mod tests { assert_eq!(idx.models_for_object("child"), vec!["ParentModel"]); } + #[test] + fn raw_assets_are_case_insensitive_and_track_effective_names() { + let audio = |name: &str| FileAsset { + kind: AssetKind::Audio, + name: name.into(), + }; + let mut idx = WorkspaceIndex::new(); + idx.set_file_assets("base", vec![audio("Click.WAV")]); + let first = idx.generation(); + assert!(idx.is_asset(AssetKind::Audio, "click.wav")); + + idx.set_file_assets("mod", vec![audio("CLICK.wav")]); + assert_eq!( + idx.generation(), + first, + "duplicate contributor changes no effective name" + ); + idx.remove_file("base"); + assert_eq!( + idx.generation(), + first, + "removing one duplicate preserves the name" + ); + idx.set_file_assets("mod", vec![audio("Other.wav")]); + assert_ne!(idx.generation(), first); + assert!(!idx.is_asset(AssetKind::Audio, "Click.wav")); + assert!(idx.is_asset(AssetKind::Audio, "OTHER.WAV")); + } + #[test] fn split_prefixed_reference_site_span_excludes_prefix() { let a = Analyzer::embedded(); diff --git a/crates/analysis/src/semantic.rs b/crates/analysis/src/semantic.rs index 52efad7..4a8d91d 100644 --- a/crates/analysis/src/semantic.rs +++ b/crates/analysis/src/semantic.rs @@ -250,7 +250,12 @@ fn value_token_kind(tok: &SyntaxToken, ty: Option<&ValueType>) -> SemKind { | Some(ValueType::ReferenceList { .. }) | Some(ValueType::W3dModel) | Some(ValueType::W3dModelList) - | Some(ValueType::W3dModelMember) => SemKind::Reference, + | Some(ValueType::W3dModelMember) + | Some(ValueType::AudioFile { .. }) + | Some(ValueType::AudioStemList) + | Some(ValueType::TextureFile) + | Some(ValueType::TextureStem) + | Some(ValueType::TextureSequenceStem) => SemKind::Reference, _ => SemKind::StringLit, } } diff --git a/crates/analysis/tests/spec.rs b/crates/analysis/tests/spec.rs index ba96947..9b9076e 100644 --- a/crates/analysis/tests/spec.rs +++ b/crates/analysis/tests/spec.rs @@ -38,7 +38,7 @@ 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, WorkspaceIndex}; +use zerosyntax_analysis::index::{definitions_in, AssetKind, FileAsset, WorkspaceIndex}; use zerosyntax_analysis::{Analyzer, Span}; use serde::Deserialize; @@ -57,6 +57,10 @@ struct Spec { complete: Vec, #[serde(default)] action: Vec, + #[serde(default)] + audio_assets: Vec, + #[serde(default)] + texture_assets: Vec, } #[derive(Deserialize)] @@ -393,6 +397,20 @@ 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_assets( + "spec-assets", + spec.audio_assets + .iter() + .map(|name| FileAsset { + kind: AssetKind::Audio, + name: name.clone(), + }) + .chain(spec.texture_assets.iter().map(|name| FileAsset { + kind: AssetKind::Texture, + name: name.clone(), + })) + .collect(), + ); let diags = diagnose(&analyzer, &parse, Some(&index), Some(&name)); if spec.no_errors { diff --git a/crates/analysis/tests/spec/Assets.ini b/crates/analysis/tests/spec/Assets.ini new file mode 100644 index 0000000..eb5c76d --- /dev/null +++ b/crates/analysis/tests/spec/Assets.ini @@ -0,0 +1,37 @@ +AudioEvent Effect + Sounds = $1Click MissingOne MissingTwo +End + +DialogEvent Dialog + Filename = $2Click.wav +End + +MusicTrack Music + Filename = $3Track.mp3 +End + +MappedImage Image + Texture = $4Particle.tga +End + +Object Thing + ShadowTexture = $5Particle + Draw = W3DModelDraw ModuleTag_Draw + TrackMarks = $7Particle.tga + End +End + +MouseCursor CursorDefinition + Texture = $6Cursor +End + +EvaEvent Announcement + SideSounds + Side = America + Sounds = $8Click MissingEva + End +End + +Weather + SnowTexture = MissingTexture.tga +End diff --git a/crates/analysis/tests/spec/Assets.spec.toml b/crates/analysis/tests/spec/Assets.spec.toml new file mode 100644 index 0000000..c798db1 --- /dev/null +++ b/crates/analysis/tests/spec/Assets.spec.toml @@ -0,0 +1,79 @@ +no_errors = true +audio_assets = ["Click.wav", "Track.mp3"] +texture_assets = ["Particle.dds", "Cursor0000.tga", "Cursor0001.tga"] + +[[complete]] +at = "$1" +includes = ["Click"] +excludes = ["Click.wav", "Track"] + +[[complete]] +at = "$2" +includes = ["Click.wav"] +excludes = ["Track.mp3"] + +[[complete]] +at = "$3" +includes = ["Track.mp3"] +excludes = ["Click.wav"] + +[[complete]] +at = "$4" +includes = ["Particle.tga"] + +[[complete]] +at = "$5" +includes = ["Particle"] + +[[complete]] +at = "$6" +includes = ["Cursor"] +excludes = ["Cursor0000", "Cursor0001"] + +[[complete]] +at = "$7" +includes = ["Particle.tga"] + +[[complete]] +at = "$8" +includes = ["Click"] +excludes = ["Click.wav", "Track"] + +[[diag]] +severity = "warning" +code = "unknown-audio-file" +on = "MissingOne" + +[[diag]] +severity = "warning" +code = "unknown-audio-file" +on = "MissingTwo" + +[[diag]] +severity = "warning" +code = "unknown-audio-file" +on = "MissingEva" + +[[diag]] +severity = "warning" +code = "unknown-texture" +on = "MissingTexture.tga" + +[[diag]] +severity = "warning" +code = "unknown-audio-file" +on = "Click.wav" +absent = true + +[[diag]] +severity = "warning" +code = "unknown-texture" +on = "Particle.tga" +absent = true + +[[diag]] +severity = "warning" +code = "unknown-texture" +on = "Particle.tga" +nth = 2 +absent = true diff --git a/crates/schema/schema.json b/crates/schema/schema.json index e9ba736..98ae348 100644 --- a/crates/schema/schema.json +++ b/crates/schema/schema.json @@ -642,7 +642,8 @@ { "name": "Filename", "value_type": { - "kind": "ascii_string" + "kind": "audio_file", + "extension": "any" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -735,7 +736,7 @@ { "name": "Sounds", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -743,7 +744,7 @@ { "name": "SoundsNight", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -751,7 +752,7 @@ { "name": "SoundsEvening", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -759,7 +760,7 @@ { "name": "SoundsMorning", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -767,7 +768,7 @@ { "name": "Attack", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -775,7 +776,7 @@ { "name": "Decay", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -1206,7 +1207,7 @@ { "name": "Texture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -1222,7 +1223,7 @@ { "name": "TextureDamaged", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -1238,7 +1239,7 @@ { "name": "TextureReallyDamaged", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -1254,7 +1255,7 @@ { "name": "TextureBroken", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -5151,7 +5152,8 @@ { "name": "Filename", "value_type": { - "kind": "ascii_string" + "kind": "audio_file", + "extension": "wav" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -5244,7 +5246,7 @@ { "name": "Sounds", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -5252,7 +5254,7 @@ { "name": "SoundsNight", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -5260,7 +5262,7 @@ { "name": "SoundsEvening", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -5268,7 +5270,7 @@ { "name": "SoundsMorning", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -5276,7 +5278,7 @@ { "name": "Attack", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -5284,7 +5286,7 @@ { "name": "Decay", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -5513,7 +5515,25 @@ ], "sub_blocks": [ { - "keyword": "SideSounds" + "keyword": "SideSounds", + "fields": [ + { + "name": "Side", + "value_type": { + "kind": "ascii_string" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Sounds", + "value_type": { + "kind": "audio_stem_list" + }, + "parse_fn": "INI::parseSoundsList", + "doc": null + } + ] } ] }, @@ -9499,91 +9519,1802 @@ ], "sub_blocks": [ { - "keyword": "A10StrikeRadiusCursor" - }, - { - "keyword": "AmbulanceRadiusCursor" - }, - { - "keyword": "AmbushRadiusCursor" - }, - { - "keyword": "AnthraxBombRadiusCursor" - }, - { - "keyword": "ArtilleryRadiusCursor" - }, - { - "keyword": "AttackContinueAreaRadiusCursor" - }, - { - "keyword": "AttackDamageAreaRadiusCursor" - }, - { - "keyword": "AttackScatterAreaRadiusCursor" - }, - { - "keyword": "CarpetBombRadiusCursor" + "keyword": "A10StrikeRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "ClearMinesRadiusCursor" + "keyword": "AmbulanceRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "ClusterMinesRadiusCursor" + "keyword": "AmbushRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "DaisyCutterRadiusCursor" + "keyword": "AnthraxBombRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "EMPPulseRadiusCursor" + "keyword": "ArtilleryRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "EmergencyRepairRadiusCursor" + "keyword": "AttackContinueAreaRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "FrenzyRadiusCursor" + "keyword": "AttackDamageAreaRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "FriendlySpecialPowerRadiusCursor" + "keyword": "AttackScatterAreaRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "GuardAreaRadiusCursor" + "keyword": "CarpetBombRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "HelixNapalmBombRadiusCursor" + "keyword": "ClearMinesRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] + }, + { + "keyword": "ClusterMinesRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "NapalmStrikeRadiusCursor" + "keyword": "DaisyCutterRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "NuclearMissileRadiusCursor" + "keyword": "EMPPulseRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "OffensiveSpecialPowerRadiusCursor" + "keyword": "EmergencyRepairRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "ParadropRadiusCursor" + "keyword": "FrenzyRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] + }, + { + "keyword": "FriendlySpecialPowerRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] + }, + { + "keyword": "GuardAreaRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] + }, + { + "keyword": "HelixNapalmBombRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] + }, + { + "keyword": "NapalmStrikeRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] + }, + { + "keyword": "NuclearMissileRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] + }, + { + "keyword": "OffensiveSpecialPowerRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] + }, + { + "keyword": "ParadropRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "ParticleCannonRadiusCursor" + "keyword": "ParticleCannonRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "RadarRadiusCursor" + "keyword": "RadarRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "ScudStormRadiusCursor" + "keyword": "ScudStormRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "SpectreGunshipRadiusCursor" + "keyword": "SpectreGunshipRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "SpyDroneRadiusCursor" + "keyword": "SpyDroneRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "SpySatelliteRadiusCursor" + "keyword": "SpySatelliteRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] }, { - "keyword": "SuperweaponScatterAreaRadiusCursor" + "keyword": "SuperweaponScatterAreaRadiusCursor", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" + }, + "parse_fn": "INI::parseAsciiString", + "doc": null + }, + { + "name": "Style", + "value_type": { + "kind": "bit_flags", + "value_set": "shadow_type" + }, + "parse_fn": "INI::parseBitString32", + "doc": null + }, + { + "name": "OpacityMin", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityMax", + "value_type": { + "kind": "percent" + }, + "parse_fn": "INI::parsePercentToReal", + "doc": null + }, + { + "name": "OpacityThrobTime", + "value_type": { + "kind": "duration" + }, + "parse_fn": "INI::parseDurationUnsignedInt", + "doc": null + }, + { + "name": "Color", + "value_type": { + "kind": "color" + }, + "parse_fn": "INI::parseColorInt", + "doc": null + }, + { + "name": "OnlyVisibleToOwningPlayer", + "value_type": { + "kind": "bool" + }, + "parse_fn": "INI::parseBool", + "doc": null + } + ] } ] }, @@ -10514,7 +12245,7 @@ { "name": "Texture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": "Texture page filename (.tga), not a MappedImage reference" @@ -11139,7 +12870,7 @@ { "name": "Texture", "value_type": { - "kind": "ascii_string" + "kind": "texture_sequence_stem" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -11304,7 +13035,8 @@ { "name": "Filename", "value_type": { - "kind": "ascii_string" + "kind": "audio_file", + "extension": "mp3" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -11397,7 +13129,7 @@ { "name": "Sounds", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -11405,7 +13137,7 @@ { "name": "SoundsNight", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -11413,7 +13145,7 @@ { "name": "SoundsEvening", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -11421,7 +13153,7 @@ { "name": "SoundsMorning", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -11429,7 +13161,7 @@ { "name": "Attack", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -11437,7 +13169,7 @@ { "name": "Decay", "value_type": { - "kind": "ascii_string_list" + "kind": "audio_stem_list" }, "parse_fn": "INI::parseSoundsList", "doc": null @@ -11784,7 +13516,7 @@ { "name": "ShadowTexture", "value_type": { - "kind": "ascii_string" + "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -13136,12 +14868,12 @@ "keyword": "DeliverPayload", "sub_blocks": [ { - "keyword": "DeliveryDecal", - "fields": [ - { - "name": "Texture", - "value_type": { - "kind": "ascii_string" + "keyword": "DeliveryDecal", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString" }, @@ -13879,7 +15611,7 @@ { "name": "ParticleName", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": "particle texture image name" @@ -14914,7 +16646,7 @@ { "name": "Texture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -15374,7 +17106,7 @@ { "name": "Texture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -15520,7 +17252,7 @@ { "name": "SkyTexture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -15528,7 +17260,7 @@ { "name": "WaterTexture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -15649,7 +17381,7 @@ { "name": "StandingWaterTexture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -15673,7 +17405,7 @@ { "name": "SkyboxTextureN", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -15681,7 +17413,7 @@ { "name": "SkyboxTextureE", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -15689,7 +17421,7 @@ { "name": "SkyboxTextureS", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -15697,7 +17429,7 @@ { "name": "SkyboxTextureW", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -15705,7 +17437,7 @@ { "name": "SkyboxTextureT", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -16443,7 +18175,7 @@ { "name": "SnowTexture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -16671,7 +18403,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -25422,12 +27154,12 @@ ], "sub_blocks": [ { - "keyword": "DeliveryDecal", - "fields": [ - { - "name": "Texture", - "value_type": { - "kind": "ascii_string" + "keyword": "DeliveryDecal", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -27246,12 +28978,12 @@ ], "sub_blocks": [ { - "keyword": "GridDecalTemplate", - "fields": [ - { - "name": "Texture", - "value_type": { - "kind": "ascii_string" + "keyword": "GridDecalTemplate", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -35070,12 +36802,12 @@ ], "sub_blocks": [ { - "keyword": "DeliveryDecal", - "fields": [ - { - "name": "Texture", - "value_type": { - "kind": "ascii_string" + "keyword": "DeliveryDecal", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -42428,12 +44160,12 @@ ], "sub_blocks": [ { - "keyword": "AttackAreaDecal", - "fields": [ - { - "name": "Texture", - "value_type": { - "kind": "ascii_string" + "keyword": "AttackAreaDecal", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -42492,12 +44224,12 @@ "doc": "RadiusDecalTemplate nested field table." }, { - "keyword": "TargetingReticleDecal", - "fields": [ - { - "name": "Texture", - "value_type": { - "kind": "ascii_string" + "keyword": "TargetingReticleDecal", + "fields": [ + { + "name": "Texture", + "value_type": { + "kind": "texture_stem" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -53608,7 +55340,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -54531,7 +56263,7 @@ { "name": "Texture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -54671,7 +56403,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -55604,7 +57336,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -56579,7 +58311,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -57675,7 +59407,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -58697,7 +60429,7 @@ { "name": "Texture", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -58848,7 +60580,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -59790,7 +61522,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -60731,7 +62463,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -61706,7 +63438,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null @@ -62731,7 +64463,7 @@ { "name": "TextureName", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "INI::parseAsciiString", "doc": null @@ -62953,7 +64685,7 @@ { "name": "TrackMarks", "value_type": { - "kind": "ascii_string" + "kind": "texture_file" }, "parse_fn": "parseAsciiStringLC", "doc": null diff --git a/crates/schema/src/lib.rs b/crates/schema/src/lib.rs index d143d94..73a78c7 100644 --- a/crates/schema/src/lib.rs +++ b/crates/schema/src/lib.rs @@ -169,6 +169,14 @@ pub enum ModelSource { ObjectReferenceField { field: String }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AudioExtension { + Any, + Wav, + Mp3, +} + /// The type of a field's value, derived from its engine parse function. /// /// The variant determines how the value tokens are validated and which @@ -209,6 +217,16 @@ pub enum ValueType { W3dModelList, /// A bone, subobject, mesh, or other member of a W3D model asset. W3dModelMember, + /// An indexed audio filename, optionally restricted by extension. + AudioFile { extension: AudioExtension }, + /// A variadic list of extensionless indexed WAV names. + AudioStemList, + /// An indexed texture filename. DDS transparently aliases the same TGA stem. + TextureFile, + /// An extensionless indexed TGA/DDS texture name. + TextureStem, + /// A texture stem that may be backed by a numbered `0000` first frame. + TextureSequenceStem, /// `R:r G:g B:b [A:a]` color. Color, /// `X:x Y:y` coordinate. @@ -1528,7 +1546,7 @@ mod tests { } let decal_fields = [ - ("Texture", ValueType::AsciiString), + ("Texture", ValueType::TextureStem), ("Style", bit_flags("shadow_type")), ("OpacityMin", ValueType::Percent), ("OpacityMax", ValueType::Percent), @@ -1554,6 +1572,14 @@ mod tests { assert!(radius.fields.is_empty()); assert!(radius.sub_blocks.is_empty()); + let cursor_blocks = &schema.index().block("InGameUI").unwrap().sub_blocks; + assert_eq!(cursor_blocks.len(), 29); + assert!(cursor_blocks.iter().all(|cursor| cursor + .fields + .iter() + .map(|field| (field.name.as_str(), field.value_type.clone())) + .eq(decal_fields.iter().cloned()))); + let ai_data = schema.index().block("AIData").unwrap(); let build_list = ai_data .sub_blocks @@ -1571,6 +1597,44 @@ mod tests { schema.index().block("EvaEvent").unwrap().defines, Some(RefKind::EvaEvent) ); + let eva_side_sounds = schema + .index() + .block("EvaEvent") + .unwrap() + .sub_blocks + .iter() + .find(|sub_block| sub_block.keyword == "SideSounds") + .unwrap(); + assert_eq!( + eva_side_sounds + .fields + .iter() + .map(|field| (field.name.as_str(), field.value_type.clone())) + .collect::>(), + [ + ("Side", ValueType::AsciiString), + ("Sounds", ValueType::AudioStemList), + ] + ); + for module in [ + "W3DDependencyModelDraw", + "W3DModelDraw", + "W3DOverlordAircraftDraw", + "W3DOverlordTankDraw", + "W3DOverlordTruckDraw", + "W3DPoliceCarDraw", + "W3DScienceModelDraw", + "W3DSupplyDraw", + "W3DTankDraw", + "W3DTankTruckDraw", + "W3DTruckDraw", + ] { + assert_eq!( + module_field(&schema, module, "TrackMarks").value_type, + ValueType::TextureFile, + "{module}.TrackMarks" + ); + } assert_eq!( schema.index().block("CrateData").unwrap().defines, Some(RefKind::CrateData) diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index 604993c..0ff071e 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -75,7 +75,7 @@ pub struct Backend { index: Arc>, /// Workspace roots, captured at `initialize` and scanned in `initialized`. roots: Mutex>, - /// User-configured game/mod INI roots. Entries may be directories or `.big` + /// User-configured game/mod INI and asset roots. Entries may be directories or `.big` /// archives; both seed definitions that map.ini/solo.ini can rely on. base_roots: Mutex>, /// Number of base INI files indexed from configured base roots. @@ -388,7 +388,7 @@ impl Backend { self.client .show_message( MessageType::WARNING, - "ZeroSyntax v2: map/solo.ini diagnostics are limited until base game or mod INIs are configured. Set `zerosyntax.baseIniRoots` to your game/mod `.big` files or INI folder.", + "ZeroSyntax v2: map/solo.ini diagnostics are limited until base game or mod data is configured. Set `zerosyntax.baseIniRoots` to your game/mod `.big` files or data folders.", ) .await; } @@ -439,7 +439,7 @@ impl Backend { let (scanned, base_scanned) = handle.await.unwrap_or_default(); let base_ini_count = base_scanned .iter() - .filter(|(_, _, _, _, _, _, models, _)| models.is_empty()) + .filter(|(_, _, _, _, _, _, models, assets, _)| models.is_empty() && assets.is_empty()) .count(); self.base_indexed_count .store(base_ini_count, Ordering::Relaxed); @@ -447,13 +447,23 @@ impl Backend { let ini_total = base_ini_count + scanned .iter() - .filter(|(_, _, _, _, _, _, models, _)| models.is_empty()) + .filter(|(_, _, _, _, _, _, models, assets, _)| { + models.is_empty() && assets.is_empty() + }) .count(); let model_total: usize = base_scanned .iter() .chain(scanned.iter()) - .map(|(_, _, _, _, _, _, models, _)| models.len()) + .map(|(_, _, _, _, _, _, models, _, _)| models.len()) .sum(); + let (audio_total, texture_total) = base_scanned + .iter() + .chain(scanned.iter()) + .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), + }); // Don't overwrite index entries for already-open documents with stale // disk content; `initialized` calls `refresh` for each open doc right // after this returns, so they will populate the index from live text. @@ -466,7 +476,7 @@ impl Backend { let Ok(mut idx) = self.index.write() else { return; }; - for (uri, defs, refs, tags, object_models, object_parents, models, text) in + for (uri, defs, refs, tags, object_models, object_parents, models, assets, text) in base_scanned.into_iter().chain(scanned) { if let Some(text) = text { @@ -479,11 +489,18 @@ impl Backend { idx.set_file_object_models(&uri, object_models); idx.set_file_object_parents(&uri, object_parents); idx.set_file_models(&uri, models); + idx.set_file_assets(&uri, assets); } } } - self.end_scan_progress(progress_token, ini_total, model_total) - .await; + self.end_scan_progress( + progress_token, + ini_total, + model_total, + audio_total, + texture_total, + ) + .await; } /// Ask the client to show an indexing spinner. Returns the token to end @@ -505,7 +522,7 @@ impl Backend { value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin( WorkDoneProgressBegin { title: "Indexing game data".into(), - message: Some("scanning workspace and base INI roots".into()), + message: Some("scanning workspace and configured game-data roots".into()), cancellable: Some(false), // Signals that reports will carry a percentage. percentage: Some(0), @@ -543,6 +560,8 @@ impl Backend { token: Option, ini_total: usize, model_total: usize, + audio_total: usize, + texture_total: usize, ) { let Some(token) = token else { return }; self.client @@ -550,7 +569,7 @@ impl Backend { token, value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd { message: Some(format!( - "{ini_total} INI files, {model_total} W3D models indexed" + "{ini_total} INI files, {model_total} W3D models, {audio_total} audio files, {texture_total} textures indexed" )), })), }) @@ -801,19 +820,38 @@ impl LanguageServer for Backend { for uri in open { self.refresh(&uri, None).await; } - let (ini, models) = { + let (ini, models, audio, textures) = { let idx = self.index.read().ok(); let models = idx .as_ref() .map(|i| i.model_names().count()) .unwrap_or_default(); - (self.base_indexed_count.load(Ordering::Relaxed), models) + let audio = idx + .as_ref() + .map(|i| { + i.asset_names(zerosyntax_analysis::index::AssetKind::Audio) + .count() + }) + .unwrap_or_default(); + let textures = idx + .as_ref() + .map(|i| { + i.asset_names(zerosyntax_analysis::index::AssetKind::Texture) + .count() + }) + .unwrap_or_default(); + ( + self.base_indexed_count.load(Ordering::Relaxed), + models, + audio, + textures, + ) }; self.client .log_message( MessageType::INFO, format!( - "zerosyntax language server ready ({ini} base INI files, {models} W3D models indexed)" + "zerosyntax language server ready ({ini} base INI files, {models} W3D models, {audio} audio files, {textures} textures indexed)" ), ) .await; @@ -1614,36 +1652,73 @@ mod tests { } #[test] - fn scans_ini_from_big_archive() { + 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 entry_name = b"Data\\INI\\Test.ini\0"; - let ini = b"Object BigArchiveObject\nEnd\n"; - let data_offset = 0x10 + 8 + entry_name.len(); - let archive_size = data_offset + ini.len(); + let entries: Vec<(&str, &[u8])> = vec![ + ("Data\\INI\\Test.ini", b"Object BigArchiveObject\nEnd\n"), + ("Art\\Good.w3d", b""), + ("Audio\\Click.WAV", b"not read"), + ("Textures\\Particle.DDS", b"not read"), + ]; + let data_offset = 0x10 + + entries + .iter() + .map(|(name, _)| 8 + name.len() + 1) + .sum::(); + let archive_size = data_offset + entries.iter().map(|(_, data)| data.len()).sum::(); let mut bytes = Vec::new(); bytes.extend_from_slice(b"BIGF"); bytes.extend_from_slice(&(archive_size as u32).to_be_bytes()); - bytes.extend_from_slice(&1u32.to_be_bytes()); + bytes.extend_from_slice(&(entries.len() as u32).to_be_bytes()); bytes.extend_from_slice(&0u32.to_be_bytes()); - bytes.extend_from_slice(&(data_offset as u32).to_be_bytes()); - bytes.extend_from_slice(&(ini.len() as u32).to_be_bytes()); - bytes.extend_from_slice(entry_name); - bytes.extend_from_slice(ini); + let mut offset = data_offset; + for (name, data) in &entries { + bytes.extend_from_slice(&(offset as u32).to_be_bytes()); + bytes.extend_from_slice(&(data.len() as u32).to_be_bytes()); + bytes.extend_from_slice(name.as_bytes()); + bytes.push(0); + offset += data.len(); + } + for (_, data) in &entries { + bytes.extend_from_slice(data); + } std::fs::write(&path, bytes).unwrap(); let analyzer = Analyzer::embedded(); let scanned = scan_big(&analyzer, &path).unwrap(); let _ = std::fs::remove_file(&path); - assert_eq!(scanned.len(), 1); - assert!(Url::parse(&scanned[0].0).is_ok()); - assert!(scanned[0].1.iter().any(|d| d.name == "BigArchiveObject")); - assert_eq!( - scanned[0].7.as_deref(), - Some("Object BigArchiveObject\nEnd\n") - ); + assert_eq!(scanned.len(), 3, "INI, W3D, and one aggregated asset entry"); + let ini = scanned.iter().find(|entry| !entry.1.is_empty()).unwrap(); + assert!(Url::parse(&ini.0).is_ok()); + assert!(ini.1.iter().any(|d| d.name == "BigArchiveObject")); + assert_eq!(ini.8.as_deref(), Some("Object BigArchiveObject\nEnd\n")); + assert!(scanned + .iter() + .any(|entry| entry.6.iter().any(|model| model.name == "Good"))); + let assets = &scanned.iter().find(|entry| !entry.7.is_empty()).unwrap().7; + assert_eq!(assets.len(), 2); + assert!(assets.iter().any(|asset| asset.name == "Click.WAV")); + assert!(assets.iter().any(|asset| asset.name == "Particle.DDS")); + } + + #[test] + fn loose_directory_scan_indexes_audio_and_texture_assets() { + let dir = std::env::temp_dir().join(format!("zerosyntax-assets-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Click.wav"), b"").unwrap(); + std::fs::write(dir.join("Particle.tga"), b"").unwrap(); + let scanned = scan_roots(&Analyzer::embedded(), std::slice::from_ref(&dir)); + std::fs::remove_dir_all(&dir).unwrap(); + let assets = scanned + .into_iter() + .flat_map(|entry| entry.7) + .collect::>(); + assert_eq!(assets.len(), 2); + assert!(assets.iter().any(|asset| asset.name == "Click.wav")); + assert!(assets.iter().any(|asset| asset.name == "Particle.tga")); } #[test] @@ -1667,13 +1742,14 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); let mut idx = WorkspaceIndex::new(); - for (uri, defs, refs, tags, object_models, object_parents, models, _) in scanned { + for (uri, defs, refs, tags, object_models, object_parents, models, assets, _) in scanned { idx.set_file(&uri, defs); idx.set_file_refs(&uri, refs); idx.set_file_tags(&uri, tags); idx.set_file_object_models(&uri, object_models); idx.set_file_object_parents(&uri, object_parents); idx.set_file_models(&uri, models); + idx.set_file_assets(&uri, assets); } assert!(idx.is_model_asset("Good"), "model name from file stem"); diff --git a/crates/server/src/cli.rs b/crates/server/src/cli.rs index a9336e5..f54fe98 100644 --- a/crates/server/src/cli.rs +++ b/crates/server/src/cli.rs @@ -56,7 +56,9 @@ fn command() -> Command { .value_name("PATH") .value_parser(value_parser!(PathBuf)) .action(ArgAction::Append) - .help("INI/W3D directory or .big archive loaded before targets"), + .help( + "INI and game assets directory or .big archive loaded before targets", + ), ) .arg( Arg::new("stdin-filename") @@ -270,13 +272,16 @@ fn has_extension(path: &Path, extension: &str) -> bool { } fn apply_entries(index: &mut WorkspaceIndex, entries: Vec) { - for (file, definitions, references, tags, object_models, object_parents, models, _) in entries { + for (file, definitions, references, tags, object_models, object_parents, models, assets, _) in + entries + { index.set_file(&file, definitions); index.set_file_refs(&file, references); index.set_file_tags(&file, tags); index.set_file_object_models(&file, object_models); index.set_file_object_parents(&file, object_parents); index.set_file_models(&file, models); + index.set_file_assets(&file, assets); } } diff --git a/crates/server/src/scan.rs b/crates/server/src/scan.rs index 26a6557..0b2e557 100644 --- a/crates/server/src/scan.rs +++ b/crates/server/src/scan.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use anyhow::{Context, Result}; use tower_lsp::lsp_types::Url; use zerosyntax_analysis::index::{ - definitions_in, module_tags_in, object_models_in, object_parents_in, references_in, Definition, - ModelAsset, ReferenceSite, + definitions_in, module_tags_in, object_models_in, object_parents_in, references_in, AssetKind, + Definition, FileAsset, ModelAsset, ReferenceSite, }; use zerosyntax_analysis::Analyzer; @@ -20,6 +20,7 @@ pub(crate) type ScanEntry = ( Vec<(String, Vec)>, Vec<(String, String)>, Vec, + Vec, Option>, ); @@ -134,6 +135,22 @@ fn file_stem_str(path: &str) -> String { .to_string() } +fn raw_asset(path: &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") { + AssetKind::Audio + } else if extension.eq_ignore_ascii_case("tga") || extension.eq_ignore_ascii_case("dds") { + AssetKind::Texture + } else { + return None; + }; + Some(FileAsset { + kind, + name: name.to_string(), + }) +} + pub(crate) fn parse_w3d_models(bytes: &[u8], fallback_name: &str) -> Vec { let mut names = Vec::new(); let mut members = Vec::new(); @@ -250,9 +267,14 @@ fn dedup_case_insensitive(values: &mut Vec) { pub(crate) fn scan_big(analyzer: &Analyzer, path: &Path) -> Result> { let mut out = Vec::new(); + let mut assets = Vec::new(); for entry in big_entries(path)? { let file = big_uri(path, &entry.name); - if entry.name.ends_with(".ini") || entry.name.ends_with(".INI") { + let extension = Path::new(&entry.name) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if extension.eq_ignore_ascii_case("ini") { let bytes = read_big_entry_bytes(path, &entry).with_context(|| { format!("failed to read {} from {}", entry.name, path.display()) })?; @@ -266,9 +288,10 @@ pub(crate) fn scan_big(analyzer: &Analyzer, path: &Path) -> Result Result Result> { if ext.eq_ignore_ascii_case("big") || ext.eq_ignore_ascii_case("ini") || ext.eq_ignore_ascii_case("w3d") + || ext.eq_ignore_ascii_case("wav") + || ext.eq_ignore_ascii_case("mp3") + || ext.eq_ignore_ascii_case("tga") + || ext.eq_ignore_ascii_case("dds") { out.push(path.to_path_buf()); } @@ -377,6 +420,7 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { object_models_in(analyzer, &parse), object_parents_in(&parse), Vec::new(), + Vec::new(), None, )]) } else if ext.eq_ignore_ascii_case("w3d") { @@ -396,10 +440,23 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { Vec::new(), Vec::new(), models, + Vec::new(), None, )) .into_iter() .collect()) + } else if let Some(asset) = raw_asset(&path.to_string_lossy()) { + Ok(vec![( + uri.to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + vec![asset], + None, + )]) } else { Ok(Vec::new()) } diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 90f55fb..eb262fc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -44,9 +44,11 @@ file. Fix error-level syntax and schema problems rather than suppressing them. | `bad-enum` | A value is not a member of the expected enum. | | `bad-flag` | A bitflag is not a member of the expected flag set. | | `bad-prefixed` | A tagged value does not use its required `Prefix:value` form. | -| `unresolved-reference` | A referenced definition is not found in the workspace or configured base INI roots. | +| `unresolved-reference` | A referenced definition is not found in the workspace or configured game-data roots. | | `unknown-model` | A model name is not found in the indexed W3D assets. | | `unknown-model-member` | A bone or subobject is not found in the models active in that scope. | +| `unknown-audio-file` | An audio filename or WAV stem is not found in indexed WAV/MP3 assets. Enabled only after audio assets are indexed. | +| `unknown-texture` | A texture filename or stem is not found in indexed TGA/DDS assets. Enabled only after texture assets are indexed. | | `unknown-suppression` | A `zerosyntax-disable` comment names an unknown code. | | `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. | diff --git a/docs/language-server.md b/docs/language-server.md index f3ce47b..a8c0225 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -33,8 +33,8 @@ indexed together before diagnostics run, so references between them resolve. Overlapping targets are checked once. `--base-root` is repeatable and accepts directories or `.big` archives -containing base/mod INIs and W3D assets. Base roots participate in reference, -model, and bone checks but do not emit diagnostics themselves. For stdin, +containing base/mod INIs and game assets. Base roots participate in reference, +model, bone, audio, and texture checks but do not emit diagnostics themselves. For stdin, `--stdin-filename` supplies the displayed/indexed name and enables `map.ini` or `solo.ini` override semantics; it defaults to ``. @@ -112,8 +112,12 @@ symbols. completions remain immediate. It defaults to `250`; valid values are `0`–`5000`, where `0` refreshes as soon as possible. - `baseIniRoots` accepts directories and `.big` archives containing base game - or mod INI files and W3D assets. Those INI definitions are treated as loaded - before `map.ini` and `solo.ini`. + or mod INI files and game assets. WAV/MP3 filenames and TGA/DDS textures power + asset completion and warnings; DDS-only textures complete as the canonical + 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`. Restart the language server after changing initialization options. diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 23f3af5..c6edf20 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -28,14 +28,16 @@ across its INI files. When editing `map.ini` or `solo.ini`, configure **ZeroSyntax v2: Base Ini Roots** with any game or mod folders and `.big` archives that load before the map. This -prevents false unresolved-reference warnings and enables W3D model and bone -checks. +prevents false unresolved-reference warnings and enables W3D model, bone, +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. ## Settings | Setting | Default | Purpose | | --- | --- | --- | -| `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for map and model checks. | +| `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for INI and game-asset checks. | | `zerosyntax.schema.path` | empty | Custom schema JSON; invalid files fall back to the built-in schema. | | `zerosyntax.analysis.modelMemberStrictness` | `compatible` | Disables member warnings, accepts any applicable model, or requires every model. | | `zerosyntax.analysis.mapOrderingDiagnostics` | `true` | Warns about source-proven forward-order problems in `map.ini` and `solo.ini`. | diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 7c4104b..1925003 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -68,7 +68,7 @@ "items": { "type": "string" }, - "markdownDescription": "Directories or `.big` archives containing base game/mod INI files and W3D assets. INI definitions are treated as already loaded before `map.ini`/`solo.ini`; W3D assets power model and bone completions/diagnostics. Changing this restarts the language server." + "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 restarts the language server." }, "zerosyntax.schema.path": { "type": "string", From 12971f59df06aebdf83545aff3b302fd653057d9 Mon Sep 17 00:00:00 2001 From: ViTeXFTW Date: Sat, 18 Jul 2026 17:38:55 +0200 Subject: [PATCH 08/10] chore: bump patch version --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11ac00c..34fa797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "zerosyntax-analysis" -version = "1.2.1" +version = "1.2.2" dependencies = [ "criterion", "rowan", @@ -1540,7 +1540,7 @@ dependencies = [ [[package]] name = "zerosyntax-schema" -version = "1.2.1" +version = "1.2.2" dependencies = [ "serde", "serde_json", @@ -1548,7 +1548,7 @@ dependencies = [ [[package]] name = "zerosyntax-server" -version = "1.2.1" +version = "1.2.2" dependencies = [ "anyhow", "clap", @@ -1568,7 +1568,7 @@ dependencies = [ [[package]] name = "zerosyntax-syntax" -version = "1.2.1" +version = "1.2.2" dependencies = [ "criterion", "logos", diff --git a/Cargo.toml b/Cargo.toml index 7a072b5..e97a05d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/schema", "crates/syntax", "crates/analysis", "crates/server"] [workspace.package] -version = "1.2.1" +version = "1.2.2" edition = "2021" license = "MIT" repository = "https://github.com/ViTeXFTW/ZeroSyntaxV2" From f2f1ad141847b2c3fd08271c953f684bce6920b0 Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:45:03 +0200 Subject: [PATCH 09/10] fix: force pushed merged features #58 #59 and #60 (#61) --- AGENTS.md | 13 +- crates/analysis/src/actions.rs | 21 +- crates/analysis/src/diagnostics.rs | 26 +- crates/analysis/src/lib.rs | 11 + .../analysis/tests/spec/QuickfixSuppress.ini | 2 +- .../tests/spec/QuickfixSuppress.spec.toml | 4 +- crates/server/src/backend.rs | 562 ++++++++++++------ crates/server/tests/e2e.py | 257 +++++++- docs/diagnostics.md | 6 +- docs/language-server.md | 19 +- editors/vscode/README.md | 18 +- editors/vscode/package.json | 19 +- editors/vscode/src/extension.ts | 47 +- editors/vscode/src/test/runTest.ts | 25 +- editors/vscode/src/test/suite/smoke.test.ts | 40 +- 15 files changed, 836 insertions(+), 234 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f3f7033..5ea7526 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,11 +138,14 @@ schema.json ──embedded──▶ schema ──▶ analysis ──▶ server `WorkspaceIndex`. `convert.rs` does byte-offset ↔ LSP position mapping in the position encoding negotiated at `initialize` (UTF-8 when the client offers it, else the UTF-16 baseline). Advertises `semanticTokens` `full` - **and** `range`. Formatting is **opt-in**: the capability is advertised - only when `initializationOptions` carries `{"format": {"enable": true}}` - (the VS Code setting `zerosyntax.format.enable`, default off — real game - files are wildly hand-indented, so format-on-save must never fire - unasked). Phase-3 numbers (`docs/phase3-incremental.md`): keystroke on the + **and** `range`. Runtime settings arrive through initialization options and + `workspace/didChangeConfiguration`; analysis switches refresh open docs, + while schema/base-root changes replace the complete index and reparse only + when the schema changes. Formatting is **opt-in** and dynamically registered + when supported (the VS Code setting `zerosyntax.format.enable`, default off — + real game files are wildly hand-indented, so format-on-save must never fire + unasked). Only the executable-path setting requires a client restart. + Phase-3 numbers (`docs/phase3-incremental.md`): keystroke on the 61k-line ParticleSystem.ini ≈ 147 µs vs 44 ms full reparse. ### Two concepts worth understanding before editing diff --git a/crates/analysis/src/actions.rs b/crates/analysis/src/actions.rs index 75cb19f..1a91864 100644 --- a/crates/analysis/src/actions.rs +++ b/crates/analysis/src/actions.rs @@ -7,7 +7,7 @@ use zerosyntax_schema::{RefKind, ValueType}; use zerosyntax_syntax::ast::{Field, Module}; use zerosyntax_syntax::{Parse, SyntaxErrorKind, SyntaxKind, SyntaxNode, SyntaxToken}; -use crate::diagnostics::{pragma_rest, pragma_words, Diagnostic, Severity}; +use crate::diagnostics::{pragma_rest, pragma_words, Diagnostic}; use crate::model::scope_schema; use crate::{nav, Analyzer, Span, WorkspaceIndex}; @@ -203,12 +203,8 @@ fn diagnostic_fixes( } _ => {} } - // Suppress pragma: warnings and hints only; never errors, never the - // misspelled-suppression hint (suppressing it is self-defeating). - if d.severity != Severity::Error - && d.code != "unknown-suppression" - && suppress_seen.insert(d.code) - { + // Suppressing the misspelled-suppression hint is self-defeating. + if d.code != "unknown-suppression" && suppress_seen.insert(d.code) { suppress_fix(parse, text, d.code, out); } } @@ -429,7 +425,7 @@ fn stub_keyword<'a>(analyzer: &'a Analyzer, kind: RefKind) -> Option<&'a str> { } /// Offer to add `; zerosyntax-disable: ` at the top of the file (or -/// append to an existing pragma line) for warning/hint diagnostics. +/// append to an existing pragma line) for a diagnostic. fn suppress_fix(parse: &Parse, text: &str, code: &'static str, out: &mut Vec) { let root = parse.syntax(); let mut first_pragma: Option<(u32, bool)> = None; // (insert offset, has_any_codes) @@ -923,17 +919,14 @@ mod tests { } #[test] - fn suppress_fix_severity_and_dedupe_rules() { - // bad-bool is Error severity → no Suppress action. + fn suppress_fix_errors_and_dedupes() { + // Error diagnostics get the same Suppress action as warnings. let src_err = "Weapon W\n ScaleWeaponSpeed = Maybe\nEnd\n"; let fx_err = all_fixes(src_err); let has_suppress_for_error = fx_err .iter() .any(|f| f.title.contains("Suppress") && f.title.contains("bad-bool")); - assert!( - !has_suppress_for_error, - "errors must not get suppress: {fx_err:?}" - ); + assert!(has_suppress_for_error, "error missing suppress: {fx_err:?}"); // Two unresolved references with the same code → only one Suppress action. let src_dup = "Weapon W\n FireFX = NoSuchFX\n FireFX = NoSuchFX2\nEnd\n"; diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index f84c222..35304ca 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -1536,12 +1536,10 @@ impl<'a> Ctx<'a> { } } } - // Stricter than the engine (which reads a bare real): require the - // `%` sign, because `Armor = X 2` almost never means 2 percent. ValueType::Percent => { - let ok = tok - .text() - .strip_suffix('%') + let value = tok.text().strip_suffix('%'); + let ok = value + .or_else(|| self.analyzer.allow_bare_percentages().then_some(tok.text())) .is_some_and(|n| n.parse::().is_ok()); if !ok { self.error( @@ -1980,6 +1978,24 @@ mod tests { assert!(diags(src).is_empty(), "{:?}", diags(src)); } + #[test] + fn bare_percentages_are_opt_in() { + let src = "Armor A\n Armor = ARMOR_PIERCING 2.5\nEnd\n"; + assert!(codes(src).contains(&"bad-percent")); + + let mut analyzer = Analyzer::embedded(); + analyzer.set_allow_bare_percentages(true); + let parse = analyzer.parse(src); + assert!(!diagnose(&analyzer, &parse, None, None) + .iter() + .any(|d| d.code == "bad-percent")); + + let malformed = analyzer.parse("Armor A\n Armor = ARMOR_PIERCING nope\nEnd\n"); + assert!(diagnose(&analyzer, &malformed, None, None) + .iter() + .any(|d| d.code == "bad-percent")); + } + #[test] fn unknown_block_is_error() { assert!(codes("Wepon AK47\nEnd\n").contains(&"unknown-block")); diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index e367f85..9e57208 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -50,6 +50,7 @@ impl From for Span { /// it. Cheap to share; build once and reuse across documents. pub struct Analyzer { schema: Schema, + allow_bare_percentages: bool, block_by_name: HashMap, module_by_name: HashMap, value_set_by_id: HashMap, @@ -88,6 +89,7 @@ impl Analyzer { let openers = SchemaOpeners::from_schema(&schema); Analyzer { schema, + allow_bare_percentages: false, block_by_name, module_by_name, value_set_by_id, @@ -105,6 +107,15 @@ impl Analyzer { &self.schema } + /// Allow engine-compatible percentage values without a trailing `%`. + pub fn set_allow_bare_percentages(&mut self, allow: bool) { + self.allow_bare_percentages = allow; + } + + pub fn allow_bare_percentages(&self) -> bool { + self.allow_bare_percentages + } + /// Parse `src` using the schema-derived opener oracle. pub fn parse(&self, src: &str) -> Parse { parse(src, &self.openers) diff --git a/crates/analysis/tests/spec/QuickfixSuppress.ini b/crates/analysis/tests/spec/QuickfixSuppress.ini index 9da9bdf..66f6183 100644 --- a/crates/analysis/tests/spec/QuickfixSuppress.ini +++ b/crates/analysis/tests/spec/QuickfixSuppress.ini @@ -1,6 +1,6 @@ ; Quickfix test: suppress-in-file pragma. -; bad-bool fires an Error on "Maybe" → no Suppress action. +; bad-bool fires an Error on "Maybe" → Suppress action offered. Weapon QFSuppressWeapon ScaleWeaponSpeed = Maybe FireFX = UnknownFXRef diff --git a/crates/analysis/tests/spec/QuickfixSuppress.spec.toml b/crates/analysis/tests/spec/QuickfixSuppress.spec.toml index 932233d..c0d0728 100644 --- a/crates/analysis/tests/spec/QuickfixSuppress.spec.toml +++ b/crates/analysis/tests/spec/QuickfixSuppress.spec.toml @@ -1,4 +1,4 @@ -# "Maybe" fires bad-bool (Error) → no Suppress quickfix +# "Maybe" fires bad-bool (Error) → Suppress quickfix offered [[diag]] severity = "error" code = "bad-bool" @@ -6,7 +6,7 @@ on = "Maybe" [[action]] on = "Maybe" -not_offers = ["Suppress"] +offers = ["Suppress"] # UnknownFXRef fires unresolved-reference (Warning) → Suppress quickfix offered [[diag]] diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index 0ff071e..c1e7226 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -7,7 +7,7 @@ //! once per change batch; read-only requests reuse the cached parse. use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::time::Duration; @@ -49,24 +49,104 @@ struct DocumentState { const DEFAULT_ANALYSIS_DEBOUNCE_MS: u64 = 250; const MAX_ANALYSIS_DEBOUNCE_MS: u64 = 5_000; +const FORMATTING_REGISTRATION_ID: &str = "zerosyntax-formatting"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RuntimeSettings { + format_enabled: bool, + schema_path: String, + base_ini_roots: Vec, + model_member_strictness: ModelMemberStrictness, + allow_bare_percentages: bool, + map_ordering_diagnostics: bool, + debounce_ms: u64, +} + +impl Default for RuntimeSettings { + fn default() -> Self { + Self { + format_enabled: false, + schema_path: String::new(), + base_ini_roots: Vec::new(), + model_member_strictness: ModelMemberStrictness::Compatible, + allow_bare_percentages: false, + map_ordering_diagnostics: true, + debounce_ms: DEFAULT_ANALYSIS_DEBOUNCE_MS, + } + } +} + +impl RuntimeSettings { + fn from_value(value: Option<&serde_json::Value>) -> Self { + let Some(value) = value else { + return Self::default(); + }; + let value = value.get("zerosyntax").unwrap_or(value); + let analysis = value.get("analysis"); + let debounce_ms = + normalized_debounce_ms(analysis.and_then(|analysis| analysis.get("debounceMs"))); + Self { + format_enabled: value + .get("format") + .and_then(|format| format.get("enable")) + .and_then(|enabled| enabled.as_bool()) + .unwrap_or(false), + schema_path: value + .get("schemaPath") + .or_else(|| value.get("schema").and_then(|schema| schema.get("path"))) + .and_then(|path| path.as_str()) + .unwrap_or_default() + .trim() + .to_string(), + base_ini_roots: value + .get("baseIniRoots") + .and_then(|roots| roots.as_array()) + .map(|roots| { + roots + .iter() + .filter_map(|root| root.as_str()) + .filter(|root| !root.trim().is_empty()) + .map(PathBuf::from) + .collect() + }) + .unwrap_or_default(), + model_member_strictness: analysis + .and_then(|analysis| analysis.get("modelMemberStrictness")) + .and_then(|value| value.as_str()) + .map(|value| match value { + "off" => ModelMemberStrictness::Off, + "strict" => ModelMemberStrictness::Strict, + _ => ModelMemberStrictness::Compatible, + }) + .unwrap_or_default(), + allow_bare_percentages: analysis + .and_then(|analysis| analysis.get("allowPercentagesWithoutSign")) + .and_then(|value| value.as_bool()) + .unwrap_or(false), + map_ordering_diagnostics: analysis + .and_then(|analysis| analysis.get("mapOrderingDiagnostics")) + .and_then(|value| value.as_bool()) + .unwrap_or(true), + debounce_ms, + } + } +} -fn analysis_debounce(options: Option<&serde_json::Value>) -> Duration { - let value = options - .and_then(|v| v.get("analysis")) - .and_then(|v| v.get("debounceMs")); - let millis = value +fn normalized_debounce_ms(value: Option<&serde_json::Value>) -> u64 { + value .and_then(|v| { v.as_i64() .map(|n| n.clamp(0, MAX_ANALYSIS_DEBOUNCE_MS as i64) as u64) .or_else(|| v.as_u64().map(|n| n.min(MAX_ANALYSIS_DEBOUNCE_MS))) }) - .unwrap_or(DEFAULT_ANALYSIS_DEBOUNCE_MS); - Duration::from_millis(millis) + .unwrap_or(DEFAULT_ANALYSIS_DEBOUNCE_MS) } pub struct Backend { client: Client, - analyzer: RwLock>, + analyzer: Arc>>, + settings: Mutex, + reload_lock: tokio::sync::Mutex<()>, schema_error: Mutex>, /// Open documents, keyed by URI. docs: Arc>, @@ -75,9 +155,6 @@ pub struct Backend { index: Arc>, /// Workspace roots, captured at `initialize` and scanned in `initialized`. roots: Mutex>, - /// User-configured game/mod INI and asset roots. Entries may be directories or `.big` - /// archives; both seed definitions that map.ini/solo.ini can rely on. - base_roots: Mutex>, /// Number of base INI files indexed from configured base roots. base_indexed_count: AtomicUsize, /// Whether the initial workspace/base scan has completed at least once. @@ -88,14 +165,14 @@ pub struct Backend { client_base_ini_hint: OnceLock, /// Position encoding negotiated at `initialize` (UTF-16 until then). encoding: OnceLock, - /// Whether `textDocument/formatting` is enabled, from the client's - /// `initializationOptions` (`{"format": {"enable": true}}`). Off by - /// default: format-on-save rewriting a whole hand-indented game file is - /// surprising, so formatting is opt-in per editor. - format_enabled: OnceLock, + /// Whether `textDocument/formatting` is currently enabled. Off by default: + /// format-on-save rewriting a whole hand-indented game file is surprising, + /// so formatting is opt-in per editor. + format_enabled: AtomicBool, + formatting_dynamic_registration: OnceLock, /// Whether source-backed map/solo.ini forward-order warnings are emitted. /// Defaults on; clients can set `analysis.mapOrderingDiagnostics` to false. - map_ordering_diagnostics: OnceLock, + map_ordering_diagnostics: Arc, /// Whether the client supports snippet insertText (tab-stops, placeholders). /// Captured at `initialize` from the client's completion-item capabilities. snippet_support: OnceLock, @@ -104,9 +181,9 @@ pub struct Backend { progress_support: OnceLock, /// Delay after the latest edit before whole-document indexes and /// diagnostics refresh. Parsing and definition-name indexing stay eager. - analysis_debounce: OnceLock, + analysis_debounce_ms: AtomicU64, /// Monotonic id source for semantic-token results (delta bookkeeping). - semantic_result_id: std::sync::atomic::AtomicU64, + semantic_result_id: AtomicU64, } fn load_schema(path: &str) -> std::result::Result { @@ -156,14 +233,6 @@ fn is_map_layer_file(file: &str) -> bool { }) } -fn map_ordering_diagnostics_option(options: Option<&serde_json::Value>) -> bool { - options - .and_then(|value| value.get("analysis")) - .and_then(|analysis| analysis.get("mapOrderingDiagnostics")) - .and_then(|value| value.as_bool()) - .unwrap_or(true) -} - fn filter_map_ordering_diagnostics( diagnostics: &mut Vec, enabled: bool, @@ -177,17 +246,18 @@ fn filter_map_ordering_diagnostics( struct RefreshOptions { enc: PositionEnc, expected_version: Option, - map_ordering_diagnostics_enabled: bool, } async fn refresh_document( client: Client, - analyzer: Arc, + analyzer: Arc>>, docs: Arc>, index: Arc>, + map_ordering_diagnostics: Arc, uri: Url, options: RefreshOptions, ) { + let analyzer = analyzer.read().expect("analyzer lock poisoned").clone(); let Some((rope, parse, version)) = docs.get(&uri).and_then(|d| { if options .expected_version @@ -244,7 +314,10 @@ async fn refresh_document( Some(uri.as_str()), &mut cache, ); - filter_map_ordering_diagnostics(&mut diags, options.map_ordering_diagnostics_enabled); + filter_map_ordering_diagnostics( + &mut diags, + map_ordering_diagnostics.load(Ordering::Relaxed), + ); diags .iter() .map(|d| convert::to_lsp_diagnostic(&rope, d, options.enc)) @@ -269,24 +342,26 @@ impl Backend { pub fn new(client: Client) -> Self { Backend { client, - analyzer: RwLock::new(Arc::new(Analyzer::embedded())), + analyzer: Arc::new(RwLock::new(Arc::new(Analyzer::embedded()))), + settings: Mutex::new(RuntimeSettings::default()), + reload_lock: tokio::sync::Mutex::new(()), schema_error: Mutex::new(None), docs: Arc::new(DashMap::new()), virtual_files: DashMap::new(), index: Arc::new(RwLock::new(WorkspaceIndex::new())), roots: Mutex::new(Vec::new()), encoding: OnceLock::new(), - format_enabled: OnceLock::new(), - map_ordering_diagnostics: OnceLock::new(), - base_roots: Mutex::new(Vec::new()), + format_enabled: AtomicBool::new(false), + formatting_dynamic_registration: OnceLock::new(), + map_ordering_diagnostics: Arc::new(AtomicBool::new(true)), base_indexed_count: AtomicUsize::new(0), scan_finished: AtomicBool::new(false), base_roots_hint_shown: AtomicBool::new(false), client_base_ini_hint: OnceLock::new(), snippet_support: OnceLock::new(), progress_support: OnceLock::new(), - analysis_debounce: OnceLock::new(), - semantic_result_id: std::sync::atomic::AtomicU64::new(1), + analysis_debounce_ms: AtomicU64::new(DEFAULT_ANALYSIS_DEBOUNCE_MS), + semantic_result_id: AtomicU64::new(1), } } @@ -302,11 +377,11 @@ impl Backend { } fn format_enabled(&self) -> bool { - self.format_enabled.get().copied().unwrap_or(false) + self.format_enabled.load(Ordering::Relaxed) } fn map_ordering_diagnostics_enabled(&self) -> bool { - self.map_ordering_diagnostics.get().copied().unwrap_or(true) + self.map_ordering_diagnostics.load(Ordering::Relaxed) } fn next_semantic_id(&self) -> u64 { @@ -320,14 +395,14 @@ impl Backend { async fn refresh(&self, uri: &Url, expected_version: Option) { refresh_document( self.client.clone(), - self.analyzer(), + self.analyzer.clone(), self.docs.clone(), self.index.clone(), + self.map_ordering_diagnostics.clone(), uri.clone(), RefreshOptions { enc: self.enc(), expected_version, - map_ordering_diagnostics_enabled: self.map_ordering_diagnostics_enabled(), }, ) .await; @@ -336,16 +411,12 @@ impl Backend { fn schedule_refresh(&self, uri: Url, version: i32) { let client = self.client.clone(); - let analyzer = self.analyzer(); + let analyzer = self.analyzer.clone(); let docs = self.docs.clone(); let index = self.index.clone(); let enc = self.enc(); - let map_ordering_diagnostics_enabled = self.map_ordering_diagnostics_enabled(); - let delay = self - .analysis_debounce - .get() - .copied() - .unwrap_or_else(|| Duration::from_millis(DEFAULT_ANALYSIS_DEBOUNCE_MS)); + let map_ordering_diagnostics = self.map_ordering_diagnostics.clone(); + let delay = Duration::from_millis(self.analysis_debounce_ms.load(Ordering::Relaxed)); tokio::spawn(async move { tokio::time::sleep(delay).await; refresh_document( @@ -353,17 +424,153 @@ impl Backend { analyzer, docs, index, + map_ordering_diagnostics, uri, RefreshOptions { enc, expected_version: Some(version), - map_ordering_diagnostics_enabled, }, ) .await; }); } + async fn refresh_all(&self) { + let open: Vec = self + .docs + .iter() + .map(|document| document.key().clone()) + .collect(); + for uri in open { + self.refresh(&uri, None).await; + } + } + + fn clear_diagnostic_caches(&self) { + for mut document in self.docs.iter_mut() { + document.diag_cache = DiagnosticsCache::new(); + } + } + + async fn set_formatting_enabled(&self, enabled: bool) { + self.format_enabled.store(enabled, Ordering::Relaxed); + if !self + .formatting_dynamic_registration + .get() + .copied() + .unwrap_or(false) + { + return; + } + let result = if enabled { + self.client + .register_capability(vec![Registration { + id: FORMATTING_REGISTRATION_ID.into(), + method: "textDocument/formatting".into(), + register_options: Some(serde_json::json!({ + "documentSelector": [{"scheme": "file", "language": "generals-ini"}] + })), + }]) + .await + } else { + // The request guard is already false, so a client that fails to + // unregister can only receive a harmless null response. + self.client + .unregister_capability(vec![Unregistration { + id: FORMATTING_REGISTRATION_ID.into(), + method: "textDocument/formatting".into(), + }]) + .await + }; + if let Err(error) = result { + self.client + .log_message( + MessageType::ERROR, + format!("failed to update formatting capability: {error}"), + ) + .await; + } + } + + async fn apply_settings(&self, settings: RuntimeSettings) { + let _reload = self.reload_lock.lock().await; + let previous = { + let Ok(mut current) = self.settings.lock() else { + return; + }; + if *current == settings { + return; + } + let previous = current.clone(); + *current = settings.clone(); + previous + }; + + self.analysis_debounce_ms + .store(settings.debounce_ms, Ordering::Relaxed); + self.map_ordering_diagnostics + .store(settings.map_ordering_diagnostics, Ordering::Relaxed); + if previous.format_enabled != settings.format_enabled { + self.set_formatting_enabled(settings.format_enabled).await; + } + + let schema_changed = previous.schema_path != settings.schema_path; + let roots_changed = previous.base_ini_roots != settings.base_ini_roots; + let bare_changed = previous.allow_bare_percentages != settings.allow_bare_percentages; + let strictness_changed = + previous.model_member_strictness != settings.model_member_strictness; + let map_ordering_changed = + previous.map_ordering_diagnostics != settings.map_ordering_diagnostics; + + if schema_changed || roots_changed { + let (mut analyzer, warning) = if schema_changed { + if settings.schema_path.is_empty() { + (Analyzer::embedded(), None) + } else { + load_schema_or_embedded(&settings.schema_path) + } + } else if bare_changed { + (Analyzer::new(self.analyzer().schema().clone()), None) + } else { + self.scan_workspace(self.analyzer(), false).await; + self.refresh_all().await; + return; + }; + analyzer.set_allow_bare_percentages(settings.allow_bare_percentages); + let analyzer = Arc::new(analyzer); + if bare_changed && !schema_changed { + if let Ok(mut current) = self.analyzer.write() { + *current = analyzer.clone(); + } + } + if let Some(warning) = warning { + self.client + .show_message(MessageType::WARNING, warning) + .await; + } + self.scan_workspace(analyzer, schema_changed).await; + self.refresh_all().await; + return; + } + + if bare_changed { + let mut analyzer = Analyzer::new(self.analyzer().schema().clone()); + analyzer.set_allow_bare_percentages(settings.allow_bare_percentages); + if let Ok(mut current) = self.analyzer.write() { + *current = Arc::new(analyzer); + } + self.clear_diagnostic_caches(); + } + if strictness_changed { + if let Ok(mut index) = self.index.write() { + index.set_model_member_strictness(settings.model_member_strictness); + } + } + if bare_changed || strictness_changed || map_ordering_changed { + self.refresh_all().await; + } + } + async fn maybe_warn_missing_base_roots(&self, uri: &Url) { if !is_map_layer_file(uri.as_str()) { return; @@ -374,7 +581,11 @@ impl Backend { if self.base_indexed_count.load(Ordering::Relaxed) > 0 { return; } - let roots_empty = self.base_roots.lock().map(|r| r.is_empty()).unwrap_or(true); + let roots_empty = self + .settings + .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; } @@ -400,18 +611,23 @@ impl Backend { /// 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) { + async fn scan_workspace(&self, analyzer: Arc, replace_analyzer: bool) { let progress_token = self.begin_scan_progress().await; let roots = self.roots.lock().map(|r| r.clone()).unwrap_or_default(); - let base_roots = self - .base_roots + let (base_roots, model_member_strictness) = self + .settings .lock() - .map(|r| r.clone()) + .map(|settings| { + ( + settings.base_ini_roots.clone(), + settings.model_member_strictness, + ) + }) .unwrap_or_default(); - let analyzer = self.analyzer(); // 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)>(); + 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); @@ -428,8 +644,8 @@ impl Backend { let _ = tx.send((done, total)); } }; - let scanned = scan_files(&analyzer, &workspace_paths, &mut progress); - let base_scanned = scan_files(&analyzer, &base_paths, &mut progress); + let scanned = scan_files(&scan_analyzer, &workspace_paths, &mut progress); + let base_scanned = scan_files(&scan_analyzer, &base_paths, &mut progress); (scanned, base_scanned) }); while let Some((done, total)) = rx.recv().await { @@ -437,6 +653,18 @@ impl Backend { .await; } let (scanned, base_scanned) = handle.await.unwrap_or_default(); + + if replace_analyzer { + if let Ok(mut current) = self.analyzer.write() { + *current = analyzer.clone(); + } + for mut document in self.docs.iter_mut() { + document.parse = Arc::new(analyzer.parse(&document.text)); + document.diag_cache = DiagnosticsCache::new(); + document.last_semantic = None; + } + } + let base_ini_count = base_scanned .iter() .filter(|(_, _, _, _, _, _, models, assets, _)| models.is_empty() && assets.is_empty()) @@ -464,35 +692,49 @@ impl Backend { zerosyntax_analysis::index::AssetKind::Audio => (audio + 1, texture), zerosyntax_analysis::index::AssetKind::Texture => (audio, texture + 1), }); - // Don't overwrite index entries for already-open documents with stale - // disk content; `initialized` calls `refresh` for each open doc right - // after this returns, so they will populate the index from live text. + // 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 .docs .iter() .map(|e| e.key().as_str().to_string()) .collect(); + 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) { - let Ok(mut idx) = self.index.write() else { - return; - }; - for (uri, defs, refs, tags, object_models, object_parents, models, assets, text) in - base_scanned.into_iter().chain(scanned) - { - if let Some(text) = text { - self.virtual_files.insert(uri.clone(), text); - } - if !open.contains(&uri) { - idx.set_file(&uri, defs); - idx.set_file_refs(&uri, refs); - idx.set_file_tags(&uri, tags); - idx.set_file_object_models(&uri, object_models); - idx.set_file_object_parents(&uri, object_parents); - idx.set_file_models(&uri, models); - idx.set_file_assets(&uri, assets); - } + if let Some(text) = text { + self.virtual_files.insert(uri.clone(), text); } + if !open.contains(&uri) { + replacement.set_file(&uri, defs); + replacement.set_file_refs(&uri, refs); + 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.set_file_assets(&uri, assets); + } + } + for document in self.docs.iter() { + let uri = document.key(); + replacement.set_file( + uri.as_str(), + definitions_in(&analyzer, &document.parse, uri.as_str()), + ); + replacement.set_file_refs(uri.as_str(), references_in(&analyzer, &document.parse)); + replacement.set_file_tags(uri.as_str(), module_tags_in(&analyzer, &document.parse)); + replacement + .set_file_object_models(uri.as_str(), object_models_in(&analyzer, &document.parse)); + replacement.set_file_object_parents(uri.as_str(), object_parents_in(&document.parse)); + replacement.set_ini_string_keys(uri.as_str(), load_sibling_str_keys(uri)); + } + if let Ok(mut index) = self.index.write() { + *index = replacement; } + self.clear_diagnostic_caches(); self.end_scan_progress( progress_token, ini_total, @@ -661,79 +903,52 @@ impl LanguageServer for Backend { let (enc, enc_kind) = convert::negotiate_encoding(¶ms.capabilities); let _ = self.encoding.set(enc); - // Editor-facing settings arrive as `initializationOptions`; a change - // requires a client restart (the VS Code extension does this - // automatically). Shape: + // Editor-facing settings arrive as `initializationOptions`. Shape: // `{ "format": {"enable": bool}, "schemaPath": "schema.json", // "analysis": {"modelMemberStrictness": "compatible", + // "allowPercentagesWithoutSign": false, // "mapOrderingDiagnostics": true, "debounceMs": 250}, // "baseIniRoots": ["dir-or-big", ...], // "clientBaseIniHint": bool }`. - let format_enabled = params - .initialization_options - .as_ref() - .and_then(|v| v.get("format")) - .and_then(|f| f.get("enable")) - .and_then(|e| e.as_bool()) - .unwrap_or(false); - let _ = self.format_enabled.set(format_enabled); - - let map_ordering_diagnostics = - map_ordering_diagnostics_option(params.initialization_options.as_ref()); - let _ = self.map_ordering_diagnostics.set(map_ordering_diagnostics); - - let model_member_strictness = params - .initialization_options - .as_ref() - .and_then(|v| v.get("analysis")) - .and_then(|v| v.get("modelMemberStrictness")) - .and_then(|v| v.as_str()) - .map(|value| match value { - "off" => ModelMemberStrictness::Off, - "strict" => ModelMemberStrictness::Strict, - _ => ModelMemberStrictness::Compatible, - }) - .unwrap_or_default(); - let _ = self - .analysis_debounce - .set(analysis_debounce(params.initialization_options.as_ref())); + let settings = RuntimeSettings::from_value(params.initialization_options.as_ref()); + self.format_enabled + .store(settings.format_enabled, Ordering::Relaxed); + self.map_ordering_diagnostics + .store(settings.map_ordering_diagnostics, Ordering::Relaxed); + self.analysis_debounce_ms + .store(settings.debounce_ms, Ordering::Relaxed); if let Ok(mut index) = self.index.write() { - index.set_model_member_strictness(model_member_strictness); + index.set_model_member_strictness(settings.model_member_strictness); } - if let Some(path) = params - .initialization_options - .as_ref() - .and_then(|v| v.get("schemaPath")) - .and_then(|v| v.as_str()) - .filter(|path| !path.trim().is_empty()) - { - let (analyzer, error) = load_schema_or_embedded(path); + if !settings.schema_path.is_empty() { + let (mut analyzer, error) = load_schema_or_embedded(&settings.schema_path); + analyzer.set_allow_bare_percentages(settings.allow_bare_percentages); if let Ok(mut current) = self.analyzer.write() { *current = Arc::new(analyzer); } if let Ok(mut current) = self.schema_error.lock() { *current = error; } + } else if settings.allow_bare_percentages { + if let Ok(mut current) = self.analyzer.write() { + Arc::get_mut(&mut current) + .expect("analyzer shared before initialization completed") + .set_allow_bare_percentages(true); + } + } + if let Ok(mut current) = self.settings.lock() { + *current = settings.clone(); } - let base_roots = params - .initialization_options + let dynamic_formatting = params + .capabilities + .text_document .as_ref() - .and_then(|v| v.get("baseIniRoots")) - .and_then(|v| v.as_array()) - .map(|roots| { - roots - .iter() - .filter_map(|root| root.as_str()) - .filter(|root| !root.trim().is_empty()) - .map(PathBuf::from) - .collect::>() - }) - .unwrap_or_default(); - if let Ok(mut roots) = self.base_roots.lock() { - *roots = base_roots; - } + .and_then(|text| text.formatting.as_ref()) + .and_then(|formatting| formatting.dynamic_registration) + .unwrap_or(false); + let _ = self.formatting_dynamic_registration.set(dynamic_formatting); let client_base_ini_hint = params .initialization_options .as_ref() @@ -796,7 +1011,8 @@ impl LanguageServer for Backend { })), // Only advertised when opted in, so format-on-save in clients // never invokes a formatter the user didn't ask for. - document_formatting_provider: format_enabled.then_some(OneOf::Left(true)), + document_formatting_provider: (!dynamic_formatting && settings.format_enabled) + .then_some(OneOf::Left(true)), code_action_provider: Some(CodeActionProviderCapability::Options( CodeActionOptions { code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]), @@ -812,14 +1028,14 @@ impl LanguageServer for Backend { if let Some(error) = self.schema_error.lock().ok().and_then(|mut e| e.take()) { self.client.show_message(MessageType::WARNING, error).await; } - self.scan_workspace().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. - let open: Vec = self.docs.iter().map(|e| e.key().clone()).collect(); - for uri in open { - self.refresh(&uri, None).await; - } + self.refresh_all().await; let (ini, models, audio, textures) = { let idx = self.index.read().ok(); let models = idx @@ -861,6 +1077,11 @@ impl LanguageServer for Backend { Ok(()) } + async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { + self.apply_settings(RuntimeSettings::from_value(Some(¶ms.settings))) + .await; + } + async fn did_open(&self, params: DidOpenTextDocumentParams) { let uri = canonical_uri(params.text_document.uri); let text: Arc = params.text_document.text.into(); @@ -1554,13 +1775,13 @@ mod tests { #[test] fn map_ordering_diagnostics_can_be_disabled() { - assert!(map_ordering_diagnostics_option(None)); - assert!(map_ordering_diagnostics_option(Some( - &serde_json::json!({}) - ))); - assert!(!map_ordering_diagnostics_option(Some( - &serde_json::json!({"analysis": {"mapOrderingDiagnostics": false}}) - ))); + assert!(RuntimeSettings::from_value(None).map_ordering_diagnostics); + assert!( + !RuntimeSettings::from_value(Some(&serde_json::json!({ + "zerosyntax": {"analysis": {"mapOrderingDiagnostics": false}} + }))) + .map_ordering_diagnostics + ); let mut diagnostics = vec![ zerosyntax_analysis::Diagnostic { @@ -1583,27 +1804,32 @@ mod tests { #[test] fn analysis_debounce_defaults_overrides_and_clamps() { - assert_eq!(analysis_debounce(None), Duration::from_millis(250)); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 0}}))), - Duration::ZERO - ); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 400}}))), - Duration::from_millis(400) - ); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": -1}}))), - Duration::ZERO - ); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 9000}}))), - Duration::from_millis(5000) - ); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 12.5}}))), - Duration::from_millis(250) - ); + assert_eq!(normalized_debounce_ms(None), 250); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(0))), 0); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(400))), 400); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(-1))), 0); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(9000))), 5000); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(12.5))), 250); + } + + #[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"], + "analysis": {"modelMemberStrictness": "strict", "debounceMs": 9000} + }))); + let notification = RuntimeSettings::from_value(Some(&serde_json::json!({ + "zerosyntax": { + "format": {"enable": true}, + "schema": {"path": "schema.json"}, + "baseIniRoots": ["base"], + "analysis": {"modelMemberStrictness": "strict", "debounceMs": 9000} + } + }))); + assert_eq!(startup, notification); + assert_eq!(startup.debounce_ms, 5000); } #[test] diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index 3a1f2f2..a933838 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -13,6 +13,7 @@ import sys import threading import queue +import struct def frame(obj: dict) -> bytes: @@ -58,6 +59,17 @@ def main() -> int: workspace = pathlib.Path(tempfile.mkdtemp(prefix="zerosyntax-e2e-")) (workspace / "Images.INI").write_text("MappedImage TestScanImage\nEnd\n") + base = pathlib.Path(tempfile.mkdtemp(prefix="zerosyntax-e2e-base-")) + (base / "Base.ini").write_text("MappedImage HotBaseImage\nEnd\n") + (base / "HotSound.wav").write_bytes(b"") + (base / "HotTexture.dds").write_bytes(b"") + + def w3d_pivot(name): + payload = name.encode("ascii") + b"\0" * (60 - len(name)) + return struct.pack(" int: ) q: "queue.Queue" = queue.Queue() threading.Thread(target=reader, args=(proc.stdout, q), daemon=True).start() + server_requests = [] + indexing_begins = [] def send(obj): proc.stdin.write(frame(obj)) @@ -87,18 +101,45 @@ def wait_for(pred, what, timeout=15.0): break if msg is None: break + if msg.get("method") in { + "client/registerCapability", + "client/unregisterCapability", + "window/workDoneProgress/create", + } and "id" in msg: + server_requests.append(msg) + send({"jsonrpc": "2.0", "id": msg["id"], "result": None}) + if (msg.get("method") == "$/progress" + and msg.get("params", {}).get("value", {}).get("kind") == "begin"): + indexing_begins.append(msg) if pred(msg): return msg print(f"TIMEOUT waiting for {what}", file=sys.stderr) return None - # 1) initialize (with a workspace root so scan_workspace runs). Formatting - # is opt-in via initializationOptions; this session opts in so the - # formatting checks below run, and step 10 verifies the default is off. + runtime_settings = { + "format": {"enable": False}, + "baseIniRoots": [], + "schema": {"path": ""}, + "analysis": { + "modelMemberStrictness": "compatible", + "allowPercentagesWithoutSign": False, + "mapOrderingDiagnostics": True, + "debounceMs": 50, + }, + } + + def configure(): + send({"jsonrpc": "2.0", "method": "workspace/didChangeConfiguration", + "params": {"settings": {"zerosyntax": runtime_settings}}}) + + # 1) initialize with dynamic formatting and progress support. send({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"capabilities": {}, "workspaceFolders": None, "rootUri": root_uri, + "params": {"capabilities": { + "textDocument": {"formatting": {"dynamicRegistration": True}}, + "window": {"workDoneProgress": True}, + }, "workspaceFolders": None, "rootUri": root_uri, "initializationOptions": { - "format": {"enable": True}, + "format": {"enable": False}, "analysis": {"debounceMs": 50}, }}}) init = wait_for(lambda m: m.get("id") == 1 and "result" in m, "initialize result") @@ -110,6 +151,8 @@ def wait_for(pred, what, timeout=15.0): assert sync == 2, f"expected INCREMENTAL sync (2), got {sync!r}" # We offered no positionEncodings, so the server must stay on the baseline. assert caps.get("positionEncoding", "utf-16") == "utf-16", caps.get("positionEncoding") + assert "documentFormattingProvider" not in caps, \ + "dynamic clients must not receive a static formatting capability" print("OK: initialize advertised capabilities (incremental sync, utf-16)") send({"jsonrpc": "2.0", "method": "initialized", "params": {}}) @@ -402,10 +445,210 @@ def latest_burst_diag(message): assert "error" in bad, f"expected error for invalid name, got {bad}" print("OK: rename edits definition + references; invalid names rejected") - # 8) Phase-6 batch 2: semanticTokens delta, formatting, code actions. + # 8) Every runtime option hot-reloads without reopening documents. + percent_uri = "file:///test/percent.ini" + percent = open_doc(percent_uri, "Armor HotArmor\n Armor = ARMOR_PIERCING 2\nEnd\n") + assert "bad-percent" in [d.get("code") for d in percent["diagnostics"]] + runtime_settings["analysis"]["debounceMs"] = 300 + configure() + send({"jsonrpc": "2.0", "method": "textDocument/didChange", + "params": {"textDocument": {"uri": percent_uri, "version": 2}, + "contentChanges": [{"text": + "Armor HotArmor2\n Armor = ARMOR_PIERCING 2\nEnd\n"}]}}) + runtime_settings["analysis"]["allowPercentagesWithoutSign"] = True + configure() + percent = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == percent_uri, + "bare-percentage enable diagnostics", + ) + assert "bad-percent" not in [d.get("code") for d in percent["params"]["diagnostics"]] + delayed = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == percent_uri + and m["params"].get("version") == 2, + "pre-reload delayed diagnostics", + timeout=2.0, + ) + assert "bad-percent" not in [d.get("code") for d in delayed["params"]["diagnostics"]] + runtime_settings["analysis"]["allowPercentagesWithoutSign"] = False + configure() + percent = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == percent_uri, + "bare-percentage disable diagnostics", + ) + assert "bad-percent" in [d.get("code") for d in percent["params"]["diagnostics"]] + + map_uri = "file:///test/map.ini" + map_text = ("CommandSet HotSet\n 1 = Command_HotLate\nEnd\n" + "CommandButton Command_HotLate\n Command = UNIT_BUILD\nEnd\n") + map_diag = open_doc(map_uri, map_text) + assert "map-forward-reference" in [d.get("code") for d in map_diag["diagnostics"]] + runtime_settings["analysis"]["mapOrderingDiagnostics"] = False + configure() + map_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == map_uri, + "map-ordering disable diagnostics", + ) + assert "map-forward-reference" not in [d.get("code") for d in map_diag["params"]["diagnostics"]] + runtime_settings["analysis"]["mapOrderingDiagnostics"] = True + configure() + map_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == map_uri, + "map-ordering enable diagnostics", + ) + assert "map-forward-reference" in [d.get("code") for d in map_diag["params"]["diagnostics"]] + print("OK: percentage and map-ordering diagnostics hot-toggle") + + runtime_settings["analysis"]["debounceMs"] = 0 + configure() + percent = change_doc(percent_uri, 3, [{"text": + "Armor HotArmor3\n Armor = ARMOR_PIERCING 2\nEnd\n"}]) + assert percent.get("version") == 3 + print("OK: debounce hot-reloads and publishes the current document version") + + progress_before = len(indexing_begins) + runtime_settings["baseIniRoots"] = [str(base)] + 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 + + asset_uri = "file:///test/hot-assets.ini" + open_doc(asset_uri, ("Object HotAssetObject\n ButtonImage = \nEnd\n" + "DialogEvent HotDialog\n Filename = \nEnd\n" + "MappedImage HotMapped\n Texture = \nEnd\n")) + request_id = 30 + + def completion_labels(doc_uri, line, character): + nonlocal request_id + request_id += 1 + send({"jsonrpc": "2.0", "id": request_id, + "method": "textDocument/completion", + "params": {"textDocument": {"uri": doc_uri}, + "position": {"line": line, "character": character}}}) + result = wait_for(lambda m: m.get("id") == request_id and "result" in m, + f"completion {request_id}") + items = result["result"] + if isinstance(items, dict): + 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) + + model_uri = "file:///test/hot-model.ini" + model_text = ("Object HotModelObject\n" + " Draw = W3DModelDraw ModuleTag_Draw\n" + " DefaultConditionState\n" + " Model = A\n" + " Model = B\n" + " HideSubObject = Bone01\n" + " End\n End\nEnd\n") + model_diag = open_doc(model_uri, model_text) + assert "unknown-model-member" not in [d.get("code") for d in model_diag["diagnostics"]] + runtime_settings["analysis"]["modelMemberStrictness"] = "strict" + configure() + model_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == model_uri, + "strict model-member diagnostics", + ) + assert "unknown-model-member" in [d.get("code") for d in model_diag["params"]["diagnostics"]] + runtime_settings["analysis"]["modelMemberStrictness"] = "compatible" + configure() + model_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == model_uri, + "compatible model-member diagnostics", + ) + assert "unknown-model-member" not in [d.get("code") for d in model_diag["params"]["diagnostics"]] + print("OK: base roots add definitions/assets/models; strictness republishes") + + runtime_settings["baseIniRoots"] = [] + configure() + wait_for( + lambda m: m.get("method") == "$/progress" + and m.get("params", {}).get("value", {}).get("kind") == "end", + "base-root removal indexing", + ) + assert "HotBaseImage" not in completion_labels(asset_uri, 1, 16) + assert "HotSound.wav" not in completion_labels(asset_uri, 4, 13) + assert "HotTexture.tga" not in completion_labels(asset_uri, 7, 12) + print("OK: removing a base root removes definitions, audio, and textures") + + custom_uri = "file:///test/custom-open.ini" + custom = open_doc(custom_uri, "TestBlock HotCustom\n CustomOnly = Yes\nEnd\n") + assert "unknown-block" in [d.get("code") for d in custom["diagnostics"]] + custom_schema = pathlib.Path(__file__).parent / "fixtures" / "custom-schema.json" + runtime_settings["schema"]["path"] = str(custom_schema.resolve()) + configure() + custom = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == custom_uri, + "custom-schema diagnostics", + ) + assert "unknown-block" not in [d.get("code") for d in custom["params"]["diagnostics"]] + runtime_settings["schema"]["path"] = "" + configure() + custom = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == custom_uri, + "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") + + runtime_settings["format"]["enable"] = True + configure() + registered = wait_for( + lambda m: m.get("method") == "client/registerCapability", + "dynamic formatting registration", + ) + assert registered["params"]["registrations"][0]["id"] == "zerosyntax-formatting" + runtime_settings["format"]["enable"] = False + configure() + unregistered = wait_for( + lambda m: m.get("method") == "client/unregisterCapability", + "dynamic formatting unregistration", + ) + assert unregistered["params"]["unregisterations"][0]["id"] == "zerosyntax-formatting" + send({"jsonrpc": "2.0", "id": 29, "method": "textDocument/formatting", + "params": {"textDocument": {"uri": percent_uri}, + "options": {"tabSize": 2, "insertSpaces": True}}}) + disabled = wait_for(lambda m: m.get("id") == 29, "disabled dynamic formatting") + assert disabled.get("result") is None + runtime_settings["format"]["enable"] = True + configure() + wait_for(lambda m: m.get("method") == "client/registerCapability", + "dynamic formatting re-registration") + + requests_before = len(server_requests) + progress_before = len(indexing_begins) + configure() + import time + time.sleep(0.25) + while not q.empty(): + pending = q.get_nowait() + assert pending.get("method") not in { + "client/registerCapability", "client/unregisterCapability" + }, pending + assert not (pending.get("method") == "$/progress" + and pending.get("params", {}).get("value", {}).get("kind") == "begin"), pending + assert len(server_requests) == requests_before + assert len(indexing_begins) == progress_before + print("OK: formatting hot-registers; identical settings are a no-op") + + # 9) Phase-6 batch 2: semanticTokens delta, formatting, code actions. assert caps["semanticTokensProvider"]["full"] == {"delta": True}, \ caps["semanticTokensProvider"]["full"] - assert caps.get("documentFormattingProvider"), "missing documentFormattingProvider" assert caps.get("codeActionProvider"), "missing codeActionProvider" # full (grab the resultId) -> edit -> delta must splice, not resend all. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index eb262fc..128b9b2 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -15,8 +15,8 @@ Separate multiple codes with spaces or commas. Multiple file-scope pragma lines accumulate. A misspelled code produces `unknown-suppression` instead of silently hiding nothing. -Suppressions are intended for warnings and hints that are valid for a specific -file. Fix error-level syntax and schema problems rather than suppressing them. +Suppressions can hide any diagnostic code for a specific file. Prefer fixing +error-level syntax and schema problems when possible. ## Diagnostic codes @@ -66,4 +66,4 @@ available fix. | Create a stub definition | A reference points to a missing definition that can be scaffolded safely. | | Remove an unreachable `WeaponSet` or `ArmorSet` | An upgrade-conditioned set can never activate. | | Insert a matching upgrade module or set | An object has only one side of an upgrade-conditioned weapon or armor setup. | -| Suppress a code in this file | A warning or hint is intentional for the current file. | +| Suppress a code in this file | A diagnostic is intentional for the current file. | diff --git a/docs/language-server.md b/docs/language-server.md index a8c0225..ed15c88 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -87,6 +87,7 @@ symbols. "schemaPath": "C:/Mods/MyMod/schema.json", "analysis": { "modelMemberStrictness": "compatible", + "allowPercentagesWithoutSign": false, "mapOrderingDiagnostics": true, "debounceMs": 250 }, @@ -98,13 +99,15 @@ symbols. } ``` -- `format.enable` controls whether the server advertises document formatting. - It defaults to `false`. +- `format.enable` controls document formatting. It defaults to `false` and is + dynamically registered when the client supports it. - `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 applicable model), or `strict` (member exists in every applicable model). It defaults to `compatible`. +- `analysis.allowPercentagesWithoutSign` accepts engine-compatible bare numbers + in percentage fields. It defaults to `false`, requiring the trailing `%`. - `analysis.mapOrderingDiagnostics` controls source-backed forward-order warnings in `map.ini` and `solo.ini`. It defaults to `true`. - `analysis.debounceMs` waits this many milliseconds after the latest edit @@ -119,7 +122,17 @@ symbols. avoid warnings caused by a partial asset index. INI definitions are treated as loaded before `map.ini` and `solo.ini`. -Restart the language server after changing initialization options. +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. +Identical settings are ignored. + +Clients without dynamic formatting registration keep their startup formatting +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. ## Supported LSP features diff --git a/editors/vscode/README.md b/editors/vscode/README.md index c6edf20..b2a91ba 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -37,17 +37,23 @@ textures are offered using the engine-compatible `stem.tga` spelling. | Setting | Default | Purpose | | --- | --- | --- | -| `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for INI and game-asset checks. | -| `zerosyntax.schema.path` | empty | Custom schema JSON; invalid files fall back to the built-in schema. | -| `zerosyntax.analysis.modelMemberStrictness` | `compatible` | Disables member warnings, accepts any applicable model, or requires every model. | -| `zerosyntax.analysis.mapOrderingDiagnostics` | `true` | Warns about source-proven forward-order problems in `map.ini` and `solo.ini`. | -| `zerosyntax.format.enable` | `false` | Enables indentation formatting. Changing it restarts the server. | -| `zerosyntax.server.path` | empty | Uses a custom `zerosyntax-lsp` binary instead of the bundled one. | +| `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for INI and game-asset checks; changes reindex. | +| `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.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. | 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. + ## INI file association The extension associates `.ini` files with **Generals INI**. If your workspace diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 1925003..8e4a1fb 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -55,12 +55,12 @@ "zerosyntax.server.path": { "type": "string", "default": "", - "markdownDescription": "Absolute path to the `zerosyntax-lsp` server binary. If empty, the extension uses the bundled binary under `server/`, then falls back to `zerosyntax-lsp` on your PATH." + "markdownDescription": "Absolute path to the `zerosyntax-lsp` server binary. If empty, the extension uses the bundled binary under `server/`, then falls back to `zerosyntax-lsp` on your PATH. This is the only ZeroSyntax setting whose change restarts the language server." }, "zerosyntax.format.enable": { "type": "boolean", "default": false, - "markdownDescription": "Enable document formatting (indentation normalization). When off — the default — the server does not advertise the formatting capability, so `#editor.formatOnSave#` will not invoke it for Generals INI files. Changing this restarts the language server." + "markdownDescription": "Enable document formatting (indentation normalization). When off — the default — `#editor.formatOnSave#` will not invoke it for Generals INI files." }, "zerosyntax.baseIniRoots": { "type": "array", @@ -68,30 +68,35 @@ "items": { "type": "string" }, - "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 restarts the language server." + "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.schema.path": { "type": "string", "default": "", - "markdownDescription": "Path to a custom ZeroSyntax schema JSON file. Use **ZeroSyntax: Select Custom Schema** to choose one. Invalid or unreadable files fall back to the built-in schema. Changing this restarts the language server." + "markdownDescription": "Path to a custom ZeroSyntax schema JSON file. Use **ZeroSyntax: Select Custom Schema** to choose one. Invalid or unreadable files fall back to the built-in schema. Changing this reparses open files and reindexes in the background." }, "zerosyntax.analysis.modelMemberStrictness": { "type": "string", "enum": ["off", "compatible", "strict"], "default": "compatible", - "markdownDescription": "Model-member diagnostics: off disables warnings, compatible accepts a bone/subobject present in any applicable model, and strict requires it in every applicable model. Changing this restarts the language server." + "markdownDescription": "Model-member diagnostics: off disables warnings, compatible accepts a bone/subobject present in any applicable model, and strict requires it in every applicable model." + }, + "zerosyntax.analysis.allowPercentagesWithoutSign": { + "type": "boolean", + "default": false, + "markdownDescription": "Allow engine-compatible percentage values without a trailing `%` sign." }, "zerosyntax.analysis.mapOrderingDiagnostics": { "type": "boolean", "default": true, - "markdownDescription": "Warn when map.ini or solo.ini uses a definition before an engine parser resolves it. Changing this restarts the language server." + "markdownDescription": "Warn when map.ini or solo.ini uses a definition before an engine parser resolves it." }, "zerosyntax.analysis.debounceMs": { "type": "integer", "default": 250, "minimum": 0, "maximum": 5000, - "markdownDescription": "Milliseconds to wait after typing before refreshing whole-document indexes and diagnostics. Parsing and completions remain immediate. Use `0` to refresh as soon as possible. Changing this restarts the language server." + "markdownDescription": "Milliseconds to wait after typing before refreshing whole-document indexes and diagnostics. Parsing and completions remain immediate. Use `0` to refresh as soon as possible." }, "zerosyntax.trace.server": { "type": "string", diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index ccff5f3..0ae2547 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -10,6 +10,7 @@ import { let client: LanguageClient | undefined; let baseIniRootsHintShown = false; +const allowBarePercentagesSetting = "analysis.allowPercentagesWithoutSign"; export function activate(context: vscode.ExtensionContext) { const serverPath = resolveServerPath(context); @@ -31,9 +32,9 @@ export function activate(context: vscode.ExtensionContext) { synchronize: { // Re-index when any .ini in the workspace changes on disk. fileEvents: vscode.workspace.createFileSystemWatcher("**/*.ini"), + configurationSection: "zerosyntax", }, - // Evaluated on every (re)start, so a settings-triggered restart picks up - // the current values. The server reads these once at `initialize`. + // Keep startup compatibility for clients that do not synchronize settings. initializationOptions: () => ({ format: { enable: setting("format.enable", false), @@ -42,6 +43,7 @@ export function activate(context: vscode.ExtensionContext) { schemaPath: setting("schema.path", ""), analysis: { modelMemberStrictness: setting("analysis.modelMemberStrictness", "compatible"), + allowPercentagesWithoutSign: setting(allowBarePercentagesSetting, false), mapOrderingDiagnostics: setting("analysis.mapOrderingDiagnostics", true), debounceMs: setting("analysis.debounceMs", 250), }, @@ -83,6 +85,41 @@ export function activate(context: vscode.ExtensionContext) { .update("schema.path", selected[0].fsPath, vscode.ConfigurationTarget.Workspace); } }), + vscode.commands.registerCommand("zerosyntax.allowBarePercentages", async (uri?: vscode.Uri) => { + const configuration = vscode.workspace.getConfiguration("zerosyntax", uri); + const inspected = configuration.inspect(allowBarePercentagesSetting); + const target = inspected?.workspaceFolderValue !== undefined + ? vscode.ConfigurationTarget.WorkspaceFolder + : inspected?.workspaceValue !== undefined + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + await configuration.update(allowBarePercentagesSetting, true, target); + }), + vscode.languages.registerCodeActionsProvider( + { scheme: "file", language: "generals-ini" }, + { + provideCodeActions(document, _range, actionContext) { + const diagnostics = actionContext.diagnostics.filter( + (diagnostic) => diagnostic.code === "bad-percent" + ); + if (diagnostics.length === 0) { + return []; + } + const action = new vscode.CodeAction( + "Allow percentages without `%`", + vscode.CodeActionKind.QuickFix + ); + action.diagnostics = diagnostics; + action.command = { + command: "zerosyntax.allowBarePercentages", + title: action.title, + arguments: [document.uri], + }; + return [action]; + }, + }, + { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] } + ), vscode.workspace.onDidOpenTextDocument((document) => { void maybeShowBaseIniRootsHint(document); }) @@ -91,11 +128,11 @@ export function activate(context: vscode.ExtensionContext) { void maybeShowBaseIniRootsHint(editor.document); } - // Server settings (initializationOptions, server path) are read once at - // startup, so any zerosyntax.* change needs a clean restart to apply. + // Only another executable requires another process; synchronized runtime + // settings are applied by the existing client configuration notification. context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("zerosyntax")) { + if (e.affectsConfiguration("zerosyntax.server.path")) { void client?.restart(); } }) diff --git a/editors/vscode/src/test/runTest.ts b/editors/vscode/src/test/runTest.ts index 15b9a7a..d33eff6 100644 --- a/editors/vscode/src/test/runTest.ts +++ b/editors/vscode/src/test/runTest.ts @@ -1,15 +1,30 @@ +import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; import { runTests } from "@vscode/test-electron"; 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"); + fs.writeFileSync( + testWorkspaceFile, + JSON.stringify({ + folders: [{ path: "." }], + settings: { "zerosyntax.analysis.allowPercentagesWithoutSign": false }, + }) + ); - await runTests({ - extensionDevelopmentPath, - extensionTestsPath, - launchArgs: ["--disable-extensions"], - }); + try { + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [testWorkspaceFile, "--disable-extensions"], + }); + } finally { + fs.rmSync(testWorkspace, { recursive: true, force: true }); + } } main().catch((err) => { diff --git a/editors/vscode/src/test/suite/smoke.test.ts b/editors/vscode/src/test/suite/smoke.test.ts index bd97adf..69c83bd 100644 --- a/editors/vscode/src/test/suite/smoke.test.ts +++ b/editors/vscode/src/test/suite/smoke.test.ts @@ -1,6 +1,5 @@ import * as assert from "assert"; import * as fs from "fs"; -import * as os from "os"; import * as path from "path"; import * as vscode from "vscode"; @@ -10,11 +9,22 @@ suite("ZeroSyntax VS Code extension", () => { assert.ok(serverPath, "ZEROSYNTAX_LSP_PATH must point at ZeroSyntax-lsp"); assert.ok(fs.existsSync(serverPath), `${serverPath} does not exist`); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "zerosyntax-vscode-")); + const workspace = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspace, "expected the test launcher to open a workspace"); + const dir = workspace.uri.fsPath; const uri = vscode.Uri.file(path.join(dir, "Smoke.ini")); + const configuration = vscode.workspace.getConfiguration("zerosyntax", uri); + assert.strictEqual( + configuration.inspect("analysis.allowPercentagesWithoutSign")?.workspaceValue, + false, + "expected the test workspace to disable bare percentages" + ); await vscode.workspace.fs.writeFile( uri, - Buffer.from("Weapon SmokeGun\n ScaleWeaponSpeed = Maybe\n \nEnd\n") + Buffer.from( + "Weapon SmokeGun\n ScaleWeaponSpeed = Maybe\n \nEnd\n" + + "Armor SmokeArmor\n Armor = ARMOR_PIERCING 2\nEnd\n" + ) ); const document = await vscode.workspace.openTextDocument(uri); @@ -46,6 +56,30 @@ suite("ZeroSyntax VS Code extension", () => { labels.includes("PrimaryDamage"), `expected PrimaryDamage completion, got ${labels.slice(0, 10).join(", ")}` ); + + const percentDiagnostic = diagnostics.find((diag) => diag.code === "bad-percent"); + assert.ok(percentDiagnostic, "expected a bad-percent diagnostic"); + const actions = await vscode.commands.executeCommand<(vscode.CodeAction | vscode.Command)[]>( + "vscode.executeCodeActionProvider", + uri, + percentDiagnostic.range, + vscode.CodeActionKind.QuickFix.value + ); + const allow = actions.find( + (action) => action.title === "Allow percentages without `%`" + ); + assert.ok(allow, "expected the bare-percentage settings quick fix"); + await vscode.commands.executeCommand("zerosyntax.allowBarePercentages", uri); + assert.strictEqual( + configuration.inspect("analysis.allowPercentagesWithoutSign")?.workspaceValue, + true, + "expected the quick fix to override the workspace setting" + ); + await waitFor( + () => vscode.languages.getDiagnostics(uri), + (items) => items.every((diag) => diag.code !== "bad-percent"), + "hot-reloaded bare-percentage setting" + ); }); }); From 74c2d7a2ebffd1c169c1f9778847d9550264adce Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:54:50 +0200 Subject: [PATCH 10/10] chore: bump patch version (#62) --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34fa797..f39e5e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "zerosyntax-analysis" -version = "1.2.2" +version = "1.2.3" dependencies = [ "criterion", "rowan", @@ -1540,7 +1540,7 @@ dependencies = [ [[package]] name = "zerosyntax-schema" -version = "1.2.2" +version = "1.2.3" dependencies = [ "serde", "serde_json", @@ -1548,7 +1548,7 @@ dependencies = [ [[package]] name = "zerosyntax-server" -version = "1.2.2" +version = "1.2.3" dependencies = [ "anyhow", "clap", @@ -1568,7 +1568,7 @@ dependencies = [ [[package]] name = "zerosyntax-syntax" -version = "1.2.2" +version = "1.2.3" dependencies = [ "criterion", "logos", diff --git a/Cargo.toml b/Cargo.toml index e97a05d..bd6272b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/schema", "crates/syntax", "crates/analysis", "crates/server"] [workspace.package] -version = "1.2.2" +version = "1.2.3" edition = "2021" license = "MIT" repository = "https://github.com/ViTeXFTW/ZeroSyntaxV2"