From 59f32c0af4604a03241ed2c9234689ce6b983c13 Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:43:21 +0200 Subject: [PATCH 1/6] Add CLI diagnostics checker (#46) --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 15 ++ crates/server/Cargo.toml | 1 + crates/server/src/backend.rs | 381 +---------------------------- crates/server/src/cli.rs | 454 +++++++++++++++++++++++++++++++++++ crates/server/src/main.rs | 10 +- crates/server/src/scan.rs | 393 ++++++++++++++++++++++++++++++ crates/server/tests/cli.rs | 216 +++++++++++++++++ crates/server/tests/e2e.py | 4 +- docs/language-server.md | 49 ++++ 11 files changed, 1149 insertions(+), 376 deletions(-) create mode 100644 crates/server/src/cli.rs create mode 100644 crates/server/src/scan.rs create mode 100644 crates/server/tests/cli.rs diff --git a/Cargo.lock b/Cargo.lock index 5f73db6..e912565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1551,6 +1551,7 @@ name = "zerosyntax-server" version = "1.1.0" dependencies = [ "anyhow", + "clap", "dashmap 6.2.1", "ropey", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1884462..60779a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ dashmap = "6" # server (workspace indexing) walkdir = "2" +clap = { version = "4", default-features = false, features = ["std", "help", "usage", "error-context"] } # benches criterion = "0.8" diff --git a/README.md b/README.md index 8a8cfda..f4e136f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,21 @@ extract it, and configure your editor to run the binary over stdio. See the [language server guide](docs/language-server.md) for initialization options and editor integration details. +## Check files from the command line + +The same binary can run diagnostics without an editor: + +```sh +zerosyntax-lsp check Data/INI +zerosyntax-lsp check map.ini --base-root "C:/Games/Zero Hour" +zerosyntax-lsp check --json --stdin-filename map.ini - < generated.ini +``` + +This is intended for CI, pre-commit checks, and LLM edit/check loops. Errors +produce exit code 1 by default; add `--fail-on warning` for a stricter gate. +See the [standalone guide](docs/language-server.md#command-line-diagnostics) for +the complete output and exit-code contract. + ## Configure map and model checks For complete `map.ini` and `solo.ini` diagnostics, set diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 966409b..0fead88 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -22,3 +22,4 @@ tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true walkdir.workspace = true +clap.workspace = true diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index f1cfc4f..2670f02 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -6,8 +6,7 @@ //! `didChange` deltas are applied to the rope and the document is re-parsed //! once per change batch; read-only requests reuse the cached parse. -use std::io::{Read, Seek, SeekFrom}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; @@ -17,15 +16,15 @@ use serde::Deserialize; use tower_lsp::lsp_types::*; use tower_lsp::{jsonrpc::Result, Client, LanguageServer}; use zerosyntax_analysis::diagnostics::DiagnosticsCache; -use zerosyntax_analysis::index::{ - definitions_in, module_tags_in, references_in, Definition, ModelAsset, ReferenceSite, - WorkspaceIndex, -}; +use zerosyntax_analysis::index::{definitions_in, module_tags_in, references_in, WorkspaceIndex}; use zerosyntax_analysis::nav::{definition_at, hover_at, reference_at, HoverInfo}; use zerosyntax_analysis::{actions, completion, diagnostics, format, outline, semantic, Analyzer}; use zerosyntax_syntax::{Edit, Parse}; use crate::convert::{self, PositionEnc}; +use crate::scan::{collect_scan_paths, load_sibling_str_keys, read_lossy, scan_files}; +#[cfg(test)] +use crate::scan::{parse_w3d_models, scan_big, scan_roots}; /// An open document: its text (as both a rope for position math and a string /// for the parser) and the parse of that exact text. `did_open`/`did_change` @@ -82,34 +81,11 @@ pub struct Backend { semantic_result_id: std::sync::atomic::AtomicU64, } -type ScanEntry = ( - String, - Vec, - Vec, - Vec<(String, String)>, - Vec, - Option>, -); - #[derive(Deserialize)] pub struct VirtualFileParams { uri: String, } -struct BigEntry { - name: String, - offset: u64, - size: usize, -} - -/// Read a file leniently: real INIs predate UTF-8 (Windows-1252 comments), so -/// a strict `read_to_string` would silently drop them from the index. -fn read_lossy(path: &Path) -> Option { - std::fs::read(path) - .ok() - .map(|b| String::from_utf8_lossy(&b).into_owned()) -} - /// Normalise a client-supplied URI to the form [`Url::from_file_path`] produces. /// On Windows, VS Code sends `file:///c%3A/…` (percent-encoded colon, lowercase /// drive letter) while `from_file_path` produces `file:///C:/…`. The mismatch @@ -134,349 +110,6 @@ fn is_map_layer_file(file: &str) -> bool { }) } -/// Parse string table key names from a `.str` file (the Generals INI string format). -/// Each block starts with a bare key name, followed by `LANG: "text"` lines, and `END`. -fn parse_str_keys(content: &str) -> Vec { - let mut keys = Vec::new(); - let mut expect_key = true; - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with(';') { - continue; - } - if trimmed.eq_ignore_ascii_case("END") { - expect_key = true; - } else if expect_key { - keys.push(trimmed.to_string()); - expect_key = false; - } - // else: inside a block (language entries) — skip - } - keys -} - -/// Read the sibling `.str` file for `ini_url` (same directory, same basename) -/// and return its string table keys, or an empty vec if not found. -fn load_sibling_str_keys(ini_url: &Url) -> Vec { - let path = match ini_url.to_file_path() { - Ok(p) => p, - Err(_) => return Vec::new(), - }; - // map.ini → map.str (same stem, .str extension) - let str_path = path.with_extension("str"); - if let Some(text) = read_lossy(&str_path) { - return parse_str_keys(&text); - } - // Also try the uppercase variant (game data ships with mixed casing). - let str_upper = path.with_extension("STR"); - read_lossy(&str_upper) - .map(|t| parse_str_keys(&t)) - .unwrap_or_default() -} - -fn read_u32_be(reader: &mut R) -> std::io::Result { - let mut buf = [0u8; 4]; - reader.read_exact(&mut buf)?; - Ok(u32::from_be_bytes(buf)) -} - -fn read_c_string(reader: &mut R) -> std::io::Result { - let mut buf = Vec::new(); - let mut byte = [0u8; 1]; - loop { - reader.read_exact(&mut byte)?; - if byte[0] == 0 { - break; - } - buf.push(byte[0]); - } - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -fn big_entries(path: &Path) -> std::io::Result> { - let mut file = std::fs::File::open(path)?; - let mut magic = [0u8; 4]; - file.read_exact(&mut magic)?; - if &magic != b"BIGF" { - return Ok(Vec::new()); - } - - let _archive_size = read_u32_be(&mut file)?; - let count = read_u32_be(&mut file)?; - file.seek(SeekFrom::Start(0x10))?; - - let mut entries = Vec::with_capacity(count as usize); - for _ in 0..count { - let offset = read_u32_be(&mut file)? as u64; - let size = read_u32_be(&mut file)? as usize; - let name = read_c_string(&mut file)?.replace('\\', "/"); - entries.push(BigEntry { name, offset, size }); - } - Ok(entries) -} - -fn read_big_entry_bytes(path: &Path, entry: &BigEntry) -> Option> { - let mut file = std::fs::File::open(path).ok()?; - file.seek(SeekFrom::Start(entry.offset)).ok()?; - let mut bytes = vec![0; entry.size]; - file.read_exact(&mut bytes).ok()?; - Some(bytes) -} - -fn read_big_entry(path: &Path, entry: &BigEntry) -> Option { - let bytes = read_big_entry_bytes(path, entry)?; - Some(String::from_utf8_lossy(&bytes).into_owned()) -} - -fn big_uri(path: &Path, entry: &str) -> String { - let archive = path.to_string_lossy().replace('\\', "/"); - let mut uri = Url::parse("big:///").expect("static big URI is valid"); - uri.set_path(&format!("{archive}!/{entry}")); - uri.to_string() -} - -fn file_stem_str(path: &str) -> String { - let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path); - file_name - .rsplit_once('.') - .map(|(stem, _)| stem) - .unwrap_or(file_name) - .to_string() -} - -fn parse_w3d_models(bytes: &[u8], fallback_name: &str) -> Vec { - let mut names = Vec::new(); - let mut members = Vec::new(); - if !fallback_name.is_empty() { - names.push(fallback_name.to_string()); - } - walk_w3d_chunks(bytes, 0, bytes.len(), 0, &mut |kind, payload| match kind { - 0x0000_001F if payload.len() >= 40 => { - push_name(&mut members, read_fixed_name(&payload[8..24])); - push_name(&mut names, read_fixed_name(&payload[24..40])); - } - // HIERARCHY_HEADER, EMITTER_HEADER, AGGREGATE_HEADER: Version + Name[16]. - 0x0000_0101 | 0x0000_0501 | 0x0000_0601 if payload.len() >= 20 => { - push_name(&mut names, read_fixed_name(&payload[4..20])); - } - 0x0000_0102 => { - for pivot in payload.chunks_exact(60) { - push_name(&mut members, read_fixed_name(&pivot[..16])); - } - } - 0x0000_0701 if payload.len() >= 40 => { - push_name(&mut names, read_fixed_name(&payload[8..24])); - push_name(&mut names, read_fixed_name(&payload[24..40])); - } - 0x0000_0704 if payload.len() >= 36 => { - push_name(&mut members, read_fixed_name(&payload[4..36])); - } - 0x0000_0740 if payload.len() >= 40 => { - push_name(&mut members, read_fixed_name(&payload[8..40])); - } - 0x0000_0750 if payload.len() >= 48 => { - push_name(&mut members, read_fixed_name(&payload[16..48])) - } - _ => {} - }); - dedup_case_insensitive(&mut names); - dedup_case_insensitive(&mut members); - names - .into_iter() - .filter(|name| !name.is_empty()) - .map(|name| ModelAsset { - name, - members: members.clone(), - }) - .collect() -} - -/// Real W3D files nest at most a handful of levels; the cap only guards -/// against corrupt or hostile files driving unbounded recursion. -const MAX_W3D_CHUNK_DEPTH: usize = 16; - -fn walk_w3d_chunks( - bytes: &[u8], - mut pos: usize, - end: usize, - depth: usize, - f: &mut impl FnMut(u32, &[u8]), -) { - if depth > MAX_W3D_CHUNK_DEPTH { - return; - } - while pos + 8 <= end && pos + 8 <= bytes.len() { - let kind = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap()); - let size_raw = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()); - let has_children = (size_raw & 0x8000_0000) != 0 || is_w3d_container(kind); - let size = (size_raw & 0x7fff_ffff) as usize; - let payload_start = pos + 8; - let Some(payload_end) = payload_start.checked_add(size) else { - break; - }; - if payload_end > end || payload_end > bytes.len() { - break; - } - let payload = &bytes[payload_start..payload_end]; - f(kind, payload); - if has_children { - walk_w3d_chunks(bytes, payload_start, payload_end, depth + 1, f); - } - pos = payload_end; - } -} - -fn is_w3d_container(kind: u32) -> bool { - // MESH, HIERARCHY, EMITTER, AGGREGATE, HLOD, HLOD_LOD_ARRAY, - // HLOD_AGGREGATE_ARRAY (w3d_file.h; the MSB size flag is the primary - // signal, these are fallbacks for files that omit it). - matches!( - kind, - 0x0000_0000 - | 0x0000_0100 - | 0x0000_0500 - | 0x0000_0600 - | 0x0000_0700 - | 0x0000_0702 - | 0x0000_0705 - ) -} - -fn read_fixed_name(bytes: &[u8]) -> &str { - let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len()); - std::str::from_utf8(&bytes[..end]).unwrap_or("").trim() -} - -fn push_name(out: &mut Vec, name: &str) { - if name.is_empty() { - return; - } - out.push(name.to_string()); - if let Some((_, short)) = name.rsplit_once('.') { - if !short.is_empty() { - out.push(short.to_string()); - } - } -} - -fn dedup_case_insensitive(values: &mut Vec) { - let mut seen = std::collections::HashSet::new(); - values.retain(|value| seen.insert(value.to_ascii_lowercase())); -} - -fn scan_big(analyzer: &Analyzer, path: &Path) -> Vec { - let mut out = Vec::new(); - let Ok(entries) = big_entries(path) else { - return out; - }; - for entry in entries { - let file = big_uri(path, &entry.name); - if entry.name.ends_with(".ini") || entry.name.ends_with(".INI") { - let Some(text) = read_big_entry(path, &entry) else { - continue; - }; - let parse = analyzer.parse(&text); - let defs = definitions_in(analyzer, &parse, &file); - let refs = references_in(analyzer, &parse); - let tags = module_tags_in(analyzer, &parse); - out.push((file, defs, refs, tags, Vec::new(), Some(Arc::from(text)))); - } else if entry.name.ends_with(".w3d") || entry.name.ends_with(".W3D") { - let Some(bytes) = read_big_entry_bytes(path, &entry) else { - continue; - }; - let stem = file_stem_str(&entry.name); - let models = parse_w3d_models(&bytes, &stem); - if !models.is_empty() { - out.push((file, Vec::new(), Vec::new(), Vec::new(), models, None)); - } - } - } - out -} - -/// Walk `roots` and index `.ini` files plus `.w3d` model assets in one call. -/// Production code goes through `collect_scan_paths` + `scan_files` so the -/// scan can report progress; this convenience wrapper serves the tests. -#[cfg(test)] -fn scan_roots(analyzer: &Analyzer, roots: &[PathBuf]) -> Vec { - scan_files(analyzer, &collect_scan_paths(roots), &mut |_, _| {}) -} - -/// Phase 1 of the scan: a cheap walk collecting every indexable file -/// (`.ini` / `.big` / `.w3d`), so phase 2 can report `done/total` progress. -fn collect_scan_paths(roots: &[PathBuf]) -> Vec { - let mut out = Vec::new(); - for root in roots { - if root - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("big")) - { - out.push(root.clone()); - continue; - } - for entry in walkdir::WalkDir::new(root) - .into_iter() - .filter_map(|e| e.ok()) - { - let path = entry.path(); - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - if ext.eq_ignore_ascii_case("big") - || ext.eq_ignore_ascii_case("ini") - || ext.eq_ignore_ascii_case("w3d") - { - out.push(path.to_path_buf()); - } - } - } - out -} - -/// Phase 2 of the scan: parse/index each collected file, invoking -/// `progress(done, total)` after each one (a `.big` archive counts as one -/// unit of work regardless of how many entries it holds). -fn scan_files( - analyzer: &Analyzer, - paths: &[PathBuf], - progress: &mut impl FnMut(usize, usize), -) -> Vec { - let mut out = Vec::new(); - for (i, path) in paths.iter().enumerate() { - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - if ext.eq_ignore_ascii_case("big") { - out.extend(scan_big(analyzer, path)); - } else if ext.eq_ignore_ascii_case("ini") { - if let (Some(text), Ok(uri)) = (read_lossy(path), Url::from_file_path(path)) { - let parse = analyzer.parse(&text); - let defs = definitions_in(analyzer, &parse, uri.as_str()); - let refs = references_in(analyzer, &parse); - let tags = module_tags_in(analyzer, &parse); - out.push((uri.to_string(), defs, refs, tags, Vec::new(), None)); - } - } else if ext.eq_ignore_ascii_case("w3d") { - if let (Ok(bytes), Ok(uri)) = (std::fs::read(path), Url::from_file_path(path)) { - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or_default(); - let models = parse_w3d_models(&bytes, stem); - if !models.is_empty() { - out.push(( - uri.to_string(), - Vec::new(), - Vec::new(), - Vec::new(), - models, - None, - )); - } - } - } - progress(i + 1, paths.len()); - } - out -} - impl Backend { pub fn new(client: Client) -> Self { Backend { @@ -785,7 +418,7 @@ impl Backend { .map(|text| Rope::from_str(&text)); } let path = uri.to_file_path().ok()?; - read_lossy(&path).map(|s| Rope::from_str(&s)) + read_lossy(&path).ok().map(|s| Rope::from_str(&s)) } pub async fn read_virtual_file(&self, params: VirtualFileParams) -> Result> { @@ -1703,7 +1336,7 @@ mod tests { std::fs::write(&path, bytes).unwrap(); let analyzer = Analyzer::embedded(); - let scanned = scan_big(&analyzer, &path); + let scanned = scan_big(&analyzer, &path).unwrap(); let _ = std::fs::remove_file(&path); assert_eq!(scanned.len(), 1); diff --git a/crates/server/src/cli.rs b/crates/server/src/cli.rs new file mode 100644 index 0000000..3be9c84 --- /dev/null +++ b/crates/server/src/cli.rs @@ -0,0 +1,454 @@ +//! One-shot diagnostics for shells, CI, and coding agents. + +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{bail, Context, Result}; +use clap::{value_parser, Arg, ArgAction, Command}; +use serde::Serialize; +use tower_lsp::lsp_types::Url; +use zerosyntax_analysis::diagnostics; +use zerosyntax_analysis::index::{definitions_in, module_tags_in, references_in}; +use zerosyntax_analysis::{Analyzer, Diagnostic, Severity, WorkspaceIndex}; +use zerosyntax_syntax::Parse; + +use crate::scan::{ + collect_scan_paths_checked, load_sibling_str_keys, read_lossy, scan_files_checked, ScanEntry, +}; + +pub(crate) fn run(args: impl IntoIterator) -> ExitCode { + let matches = match command().try_get_matches_from(args) { + Ok(matches) => matches, + Err(error) => { + let code = error.exit_code(); + let _ = error.print(); + return ExitCode::from(code as u8); + } + }; + + let Some(("check", check)) = matches.subcommand() else { + return ExitCode::from(2); + }; + match check_command(check) { + Ok(true) => ExitCode::from(1), + Ok(false) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("zerosyntax-lsp: {error:#}"); + ExitCode::from(2) + } + } +} + +fn command() -> Command { + Command::new("zerosyntax-lsp") + .version(env!("CARGO_PKG_VERSION")) + .about("Language server and diagnostics checker for Generals INI files") + .subcommand( + Command::new("check") + .about("Check INI files and directories") + .arg( + Arg::new("base-root") + .long("base-root") + .value_name("PATH") + .value_parser(value_parser!(PathBuf)) + .action(ArgAction::Append) + .help("INI/W3D directory or .big archive loaded before targets"), + ) + .arg( + Arg::new("stdin-filename") + .long("stdin-filename") + .value_name("PATH") + .help("Display and index name for stdin (default: )"), + ) + .arg( + Arg::new("json") + .long("json") + .action(ArgAction::SetTrue) + .help("Emit a JSON diagnostic array"), + ) + .arg( + Arg::new("fail-on") + .long("fail-on") + .value_name("SEVERITY") + .value_parser(["error", "warning", "hint"]) + .default_value("error") + .help("Lowest diagnostic severity that produces exit 1"), + ) + .arg( + Arg::new("targets") + .value_name("PATH|-") + .value_parser(value_parser!(OsString)) + .num_args(1..) + .required(true), + ), + ) +} + +fn check_command(matches: &clap::ArgMatches) -> Result { + let target_args: Vec = matches + .get_many::("targets") + .expect("required by clap") + .cloned() + .collect(); + let stdin_count = target_args.iter().filter(|arg| *arg == "-").count(); + if stdin_count > 1 { + bail!("stdin target '-' may only be specified once"); + } + if matches.get_one::("stdin-filename").is_some() && stdin_count == 0 { + bail!("--stdin-filename requires the '-' target"); + } + + let base_roots = canonical_roots( + matches + .get_many::("base-root") + .into_iter() + .flatten() + .cloned(), + RootKind::Base, + )?; + let target_roots = canonical_roots( + target_args + .iter() + .filter(|arg| *arg != "-") + .map(PathBuf::from), + RootKind::Target, + )?; + + let analyzer = Analyzer::embedded(); + let mut index = WorkspaceIndex::new(); + + let mut base_paths = collect_scan_paths_checked(&base_roots)?; + sort_dedup_paths(&mut base_paths); + apply_entries(&mut index, scan_files_checked(&analyzer, &base_paths)?); + + let mut target_scan_paths = collect_scan_paths_checked(&target_roots)?; + sort_dedup_paths(&mut target_scan_paths); + apply_entries( + &mut index, + scan_files_checked(&analyzer, &target_scan_paths)?, + ); + + let cwd = std::env::current_dir().context("failed to determine current directory")?; + let mut documents = Vec::new(); + for path in target_scan_paths + .iter() + .filter(|path| has_extension(path, "ini")) + { + let document = TargetDocument::from_path(&analyzer, path, &cwd)?; + let uri = Url::parse(&document.file).expect("created from a valid file path"); + index.set_ini_string_keys(&document.file, load_sibling_str_keys(&uri)); + documents.push(document); + } + + if stdin_count == 1 { + let mut text = String::new(); + std::io::stdin() + .read_to_string(&mut text) + .context("failed to read stdin")?; + let name = matches + .get_one::("stdin-filename") + .cloned() + .unwrap_or_else(|| "".to_string()); + let parse = analyzer.parse(&text); + index_document(&mut index, &analyzer, &parse, &name); + documents.push(TargetDocument { + display: name.clone(), + file: name, + text, + parse, + }); + } + + documents.sort_by(|a, b| a.display.cmp(&b.display)); + let mut output = Vec::new(); + for document in &documents { + let line_index = LineIndex::new(&document.text); + output.extend( + diagnostics::diagnose( + &analyzer, + &document.parse, + Some(&index), + Some(&document.file), + ) + .into_iter() + .map(|diagnostic| OutputDiagnostic::new(document, &line_index, diagnostic)), + ); + } + output.sort_by(|a, b| { + ( + &a.file, + a.range.start.line, + a.range.start.column, + a.range.end.line, + a.range.end.column, + &a.code, + ) + .cmp(&( + &b.file, + b.range.start.line, + b.range.start.column, + b.range.end.line, + b.range.end.column, + &b.code, + )) + }); + + if matches.get_flag("json") { + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + serde_json::to_writer(&mut writer, &output).context("failed to write JSON output")?; + writeln!(writer).context("failed to write JSON output")?; + } else { + write_human(&output)?; + } + + let threshold = match matches + .get_one::("fail-on") + .map(String::as_str) + .expect("defaulted by clap") + { + "error" => 3, + "warning" => 2, + "hint" => 1, + _ => unreachable!("validated by clap"), + }; + Ok(output + .iter() + .any(|diagnostic| diagnostic.severity.rank() >= threshold)) +} + +#[derive(Clone, Copy)] +enum RootKind { + Base, + Target, +} + +fn canonical_roots( + roots: impl IntoIterator, + kind: RootKind, +) -> Result> { + let mut out = Vec::new(); + for root in roots { + let canonical = std::fs::canonicalize(&root) + .with_context(|| format!("cannot access {}", root.display()))?; + let metadata = std::fs::metadata(&canonical) + .with_context(|| format!("cannot inspect {}", root.display()))?; + let supported = if metadata.is_dir() { + true + } else { + match kind { + RootKind::Base => has_extension(&canonical, "big"), + RootKind::Target => has_extension(&canonical, "ini"), + } + }; + if !supported { + let expected = match kind { + RootKind::Base => "a directory or .big archive", + RootKind::Target => "a directory or .ini file", + }; + bail!("{} is not {expected}", root.display()); + } + out.push(canonical); + } + sort_dedup_paths(&mut out); + Ok(out) +} + +fn sort_dedup_paths(paths: &mut Vec) { + paths.sort(); + paths.dedup(); +} + +fn has_extension(path: &Path, extension: &str) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(extension)) +} + +fn apply_entries(index: &mut WorkspaceIndex, entries: Vec) { + for (file, definitions, references, tags, models, _) in entries { + index.set_file(&file, definitions); + index.set_file_refs(&file, references); + index.set_file_tags(&file, tags); + index.set_file_models(&file, models); + } +} + +fn index_document(index: &mut WorkspaceIndex, analyzer: &Analyzer, parse: &Parse, file: &str) { + index.set_file(file, definitions_in(analyzer, parse, file)); + index.set_file_refs(file, references_in(analyzer, parse)); + index.set_file_tags(file, module_tags_in(analyzer, parse)); +} + +struct TargetDocument { + display: String, + file: String, + text: String, + parse: Parse, +} + +impl TargetDocument { + fn from_path(analyzer: &Analyzer, path: &Path, cwd: &Path) -> Result { + let text = read_lossy(path)?; + let parse = analyzer.parse(&text); + let file = Url::from_file_path(path) + .map_err(|_| anyhow::anyhow!("cannot convert {} to a file URI", path.display()))? + .to_string(); + let display = path + .strip_prefix(cwd) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + Ok(Self { + display, + file, + text, + parse, + }) + } +} + +#[derive(Serialize)] +struct OutputDiagnostic { + file: String, + range: OutputRange, + severity: OutputSeverity, + code: &'static str, + message: String, +} + +impl OutputDiagnostic { + fn new(document: &TargetDocument, lines: &LineIndex, diagnostic: Diagnostic) -> Self { + Self { + file: document.display.clone(), + range: OutputRange { + start: lines.position(&document.text, diagnostic.span.start as usize), + end: lines.position(&document.text, diagnostic.span.end as usize), + }, + severity: diagnostic.severity.into(), + code: diagnostic.code, + message: diagnostic.message, + } + } +} + +#[derive(Serialize)] +struct OutputRange { + start: OutputPosition, + end: OutputPosition, +} + +#[derive(Serialize)] +struct OutputPosition { + line: usize, + column: usize, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +enum OutputSeverity { + Error, + Warning, + Hint, +} + +impl OutputSeverity { + fn rank(self) -> u8 { + match self { + Self::Error => 3, + Self::Warning => 2, + Self::Hint => 1, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + Self::Hint => "hint", + } + } +} + +impl From for OutputSeverity { + fn from(value: Severity) -> Self { + match value { + Severity::Error => Self::Error, + Severity::Warning => Self::Warning, + Severity::Hint => Self::Hint, + } + } +} + +struct LineIndex { + starts: Vec, +} + +impl LineIndex { + fn new(text: &str) -> Self { + let mut starts = vec![0]; + starts.extend( + text.match_indices('\n') + .map(|(offset, _)| offset.saturating_add(1)), + ); + Self { starts } + } + + fn position(&self, text: &str, offset: usize) -> OutputPosition { + let offset = offset.min(text.len()); + let line = self.starts.partition_point(|start| *start <= offset) - 1; + OutputPosition { + line: line + 1, + column: text[self.starts[line]..offset].chars().count() + 1, + } + } +} + +fn write_human(diagnostics: &[OutputDiagnostic]) -> Result<()> { + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + for diagnostic in diagnostics { + writeln!( + writer, + "{}:{}:{}: {}[{}]: {}", + diagnostic.file, + diagnostic.range.start.line, + diagnostic.range.start.column, + diagnostic.severity.as_str(), + diagnostic.code, + diagnostic.message + )?; + } + + let counts = diagnostics.iter().fold([0usize; 3], |mut counts, item| { + counts[match item.severity { + OutputSeverity::Error => 0, + OutputSeverity::Warning => 1, + OutputSeverity::Hint => 2, + }] += 1; + counts + }); + writeln!( + writer, + "{} error(s), {} warning(s), {} hint(s)", + counts[0], counts[1], counts[2] + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unicode_scalar_positions_are_one_based() { + let text = "; é🚀\nWeapon X\n"; + let index = LineIndex::new(text); + assert_eq!(index.position(text, "; é".len()).line, 1); + assert_eq!(index.position(text, "; é".len()).column, 4); + assert_eq!(index.position(text, text.find("Weapon").unwrap()).line, 2); + assert_eq!(index.position(text, text.find("Weapon").unwrap()).column, 1); + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 1e5e174..ffafb35 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -2,13 +2,20 @@ //! INI files. Speaks LSP over stdio. mod backend; +mod cli; mod convert; +mod scan; use backend::Backend; use tower_lsp::{LspService, Server}; #[tokio::main] -async fn main() { +async fn main() -> std::process::ExitCode { + let args: Vec<_> = std::env::args_os().collect(); + if args.len() > 1 && !(args.len() == 2 && args[1] == "--stdio") { + return cli::run(args); + } + // Log to stderr (stdout is reserved for the LSP wire protocol). tracing_subscriber::fmt() .with_writer(std::io::stderr) @@ -24,4 +31,5 @@ async fn main() { .custom_method("zerosyntax/readVirtualFile", Backend::read_virtual_file) .finish(); Server::new(stdin, stdout, socket).serve(service).await; + std::process::ExitCode::SUCCESS } diff --git a/crates/server/src/scan.rs b/crates/server/src/scan.rs new file mode 100644 index 0000000..f2f677b --- /dev/null +++ b/crates/server/src/scan.rs @@ -0,0 +1,393 @@ +//! Shared filesystem, BIG archive, and W3D workspace scanning. + +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tower_lsp::lsp_types::Url; +use zerosyntax_analysis::index::{ + definitions_in, module_tags_in, references_in, Definition, ModelAsset, ReferenceSite, +}; +use zerosyntax_analysis::Analyzer; + +pub(crate) type ScanEntry = ( + String, + Vec, + Vec, + Vec<(String, String)>, + Vec, + Option>, +); + +struct BigEntry { + name: String, + offset: u64, + size: usize, +} + +/// Read a file leniently: real INIs predate UTF-8 (Windows-1252 comments). +pub(crate) fn read_lossy(path: &Path) -> Result { + std::fs::read(path) + .with_context(|| format!("failed to read {}", path.display())) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) +} + +/// Load key names from an INI file's optional sibling Generals `.str` file. +pub(crate) fn load_sibling_str_keys(ini_url: &Url) -> Vec { + let Ok(path) = ini_url.to_file_path() else { + return Vec::new(); + }; + for path in [path.with_extension("str"), path.with_extension("STR")] { + if let Ok(text) = read_lossy(&path) { + return parse_str_keys(&text); + } + } + Vec::new() +} + +fn parse_str_keys(content: &str) -> Vec { + let mut keys = Vec::new(); + let mut expect_key = true; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with(';') { + continue; + } + if trimmed.eq_ignore_ascii_case("END") { + expect_key = true; + } else if expect_key { + keys.push(trimmed.to_string()); + expect_key = false; + } + } + keys +} + +fn read_u32_be(reader: &mut R) -> std::io::Result { + let mut buf = [0u8; 4]; + reader.read_exact(&mut buf)?; + Ok(u32::from_be_bytes(buf)) +} + +fn read_c_string(reader: &mut R) -> std::io::Result { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + loop { + reader.read_exact(&mut byte)?; + if byte[0] == 0 { + break; + } + buf.push(byte[0]); + } + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +fn big_entries(path: &Path) -> Result> { + let mut file = std::fs::File::open(path) + .with_context(|| format!("failed to open BIG archive {}", path.display()))?; + let mut magic = [0u8; 4]; + file.read_exact(&mut magic) + .with_context(|| format!("failed to read BIG archive {}", path.display()))?; + if &magic != b"BIGF" { + anyhow::bail!("{} is not a BIGF archive", path.display()); + } + + let _archive_size = read_u32_be(&mut file)?; + let count = read_u32_be(&mut file)?; + file.seek(SeekFrom::Start(0x10))?; + + let mut entries = Vec::with_capacity(count as usize); + for _ in 0..count { + let offset = read_u32_be(&mut file)? as u64; + let size = read_u32_be(&mut file)? as usize; + let name = read_c_string(&mut file)?.replace('\\', "/"); + entries.push(BigEntry { name, offset, size }); + } + Ok(entries) +} + +fn read_big_entry_bytes(path: &Path, entry: &BigEntry) -> Result> { + let mut file = std::fs::File::open(path)?; + file.seek(SeekFrom::Start(entry.offset))?; + let mut bytes = vec![0; entry.size]; + file.read_exact(&mut bytes)?; + Ok(bytes) +} + +fn big_uri(path: &Path, entry: &str) -> String { + let archive = path.to_string_lossy().replace('\\', "/"); + let mut uri = Url::parse("big:///").expect("static BIG URI is valid"); + uri.set_path(&format!("{archive}!/{entry}")); + uri.to_string() +} + +fn file_stem_str(path: &str) -> String { + let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path); + file_name + .rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(file_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(); + if !fallback_name.is_empty() { + names.push(fallback_name.to_string()); + } + walk_w3d_chunks(bytes, 0, bytes.len(), 0, &mut |kind, payload| match kind { + 0x0000_001F if payload.len() >= 40 => { + push_name(&mut members, read_fixed_name(&payload[8..24])); + push_name(&mut names, read_fixed_name(&payload[24..40])); + } + 0x0000_0101 | 0x0000_0501 | 0x0000_0601 if payload.len() >= 20 => { + push_name(&mut names, read_fixed_name(&payload[4..20])); + } + 0x0000_0102 => { + for pivot in payload.chunks_exact(60) { + push_name(&mut members, read_fixed_name(&pivot[..16])); + } + } + 0x0000_0701 if payload.len() >= 40 => { + push_name(&mut names, read_fixed_name(&payload[8..24])); + push_name(&mut names, read_fixed_name(&payload[24..40])); + } + 0x0000_0704 if payload.len() >= 36 => { + push_name(&mut members, read_fixed_name(&payload[4..36])); + } + 0x0000_0740 if payload.len() >= 40 => { + push_name(&mut members, read_fixed_name(&payload[8..40])); + } + 0x0000_0750 if payload.len() >= 48 => { + push_name(&mut members, read_fixed_name(&payload[16..48])); + } + _ => {} + }); + dedup_case_insensitive(&mut names); + dedup_case_insensitive(&mut members); + names + .into_iter() + .filter(|name| !name.is_empty()) + .map(|name| ModelAsset { + name, + members: members.clone(), + }) + .collect() +} + +const MAX_W3D_CHUNK_DEPTH: usize = 16; + +fn walk_w3d_chunks( + bytes: &[u8], + mut pos: usize, + end: usize, + depth: usize, + f: &mut impl FnMut(u32, &[u8]), +) { + if depth > MAX_W3D_CHUNK_DEPTH { + return; + } + while pos + 8 <= end && pos + 8 <= bytes.len() { + let kind = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap()); + let size_raw = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()); + let has_children = (size_raw & 0x8000_0000) != 0 || is_w3d_container(kind); + let size = (size_raw & 0x7fff_ffff) as usize; + let payload_start = pos + 8; + let Some(payload_end) = payload_start.checked_add(size) else { + break; + }; + if payload_end > end || payload_end > bytes.len() { + break; + } + let payload = &bytes[payload_start..payload_end]; + f(kind, payload); + if has_children { + walk_w3d_chunks(bytes, payload_start, payload_end, depth + 1, f); + } + pos = payload_end; + } +} + +fn is_w3d_container(kind: u32) -> bool { + matches!( + kind, + 0x0000_0000 + | 0x0000_0100 + | 0x0000_0500 + | 0x0000_0600 + | 0x0000_0700 + | 0x0000_0702 + | 0x0000_0705 + ) +} + +fn read_fixed_name(bytes: &[u8]) -> &str { + let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len()); + std::str::from_utf8(&bytes[..end]).unwrap_or("").trim() +} + +fn push_name(out: &mut Vec, name: &str) { + if name.is_empty() { + return; + } + out.push(name.to_string()); + if let Some((_, short)) = name.rsplit_once('.') { + if !short.is_empty() { + out.push(short.to_string()); + } + } +} + +fn dedup_case_insensitive(values: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + values.retain(|value| seen.insert(value.to_ascii_lowercase())); +} + +pub(crate) fn scan_big(analyzer: &Analyzer, path: &Path) -> Result> { + let mut out = Vec::new(); + 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 bytes = read_big_entry_bytes(path, &entry).with_context(|| { + format!("failed to read {} from {}", entry.name, path.display()) + })?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + let parse = analyzer.parse(&text); + out.push(( + file.clone(), + definitions_in(analyzer, &parse, &file), + references_in(analyzer, &parse), + module_tags_in(analyzer, &parse), + Vec::new(), + Some(Arc::from(text)), + )); + } else if entry.name.ends_with(".w3d") || entry.name.ends_with(".W3D") { + let bytes = read_big_entry_bytes(path, &entry).with_context(|| { + format!("failed to read {} from {}", entry.name, path.display()) + })?; + let models = parse_w3d_models(&bytes, &file_stem_str(&entry.name)); + if !models.is_empty() { + out.push((file, Vec::new(), Vec::new(), Vec::new(), models, None)); + } + } + } + Ok(out) +} + +/// Best-effort discovery used by the interactive server. +pub(crate) fn collect_scan_paths(roots: &[PathBuf]) -> Vec { + collect_paths(roots, false).unwrap_or_default() +} + +/// Checked discovery used by the CLI, where skipped inputs must fail visibly. +pub(crate) fn collect_scan_paths_checked(roots: &[PathBuf]) -> Result> { + collect_paths(roots, true) +} + +fn collect_paths(roots: &[PathBuf], checked: bool) -> Result> { + let mut out = Vec::new(); + for root in roots { + if root.is_file() + && root + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("big")) + { + out.push(root.clone()); + continue; + } + for entry in walkdir::WalkDir::new(root) { + let entry = match entry { + Ok(entry) => entry, + Err(error) if checked => return Err(error.into()), + Err(_) => continue, + }; + let path = entry.path(); + if !path.is_file() { + continue; + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext.eq_ignore_ascii_case("big") + || ext.eq_ignore_ascii_case("ini") + || ext.eq_ignore_ascii_case("w3d") + { + out.push(path.to_path_buf()); + } + } + } + Ok(out) +} + +/// Best-effort indexing used by the interactive server. +pub(crate) fn scan_files( + analyzer: &Analyzer, + paths: &[PathBuf], + progress: &mut impl FnMut(usize, usize), +) -> Vec { + let mut out = Vec::new(); + for (i, path) in paths.iter().enumerate() { + if let Ok(mut entries) = scan_path(analyzer, path) { + out.append(&mut entries); + } + progress(i + 1, paths.len()); + } + out +} + +pub(crate) fn scan_files_checked(analyzer: &Analyzer, paths: &[PathBuf]) -> Result> { + let mut out = Vec::new(); + for path in paths { + out.extend(scan_path(analyzer, path)?); + } + Ok(out) +} + +fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext.eq_ignore_ascii_case("big") { + return scan_big(analyzer, path); + } + + let uri = Url::from_file_path(path) + .map_err(|_| anyhow::anyhow!("cannot convert {} to a file URI", path.display()))?; + if ext.eq_ignore_ascii_case("ini") { + let text = read_lossy(path)?; + let parse = analyzer.parse(&text); + Ok(vec![( + uri.to_string(), + definitions_in(analyzer, &parse, uri.as_str()), + references_in(analyzer, &parse), + module_tags_in(analyzer, &parse), + Vec::new(), + None, + )]) + } else if ext.eq_ignore_ascii_case("w3d") { + let bytes = + std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let models = parse_w3d_models(&bytes, stem); + Ok((!models.is_empty()) + .then_some(( + uri.to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + models, + None, + )) + .into_iter() + .collect()) + } else { + Ok(Vec::new()) + } +} + +#[cfg(test)] +pub(crate) fn scan_roots(analyzer: &Analyzer, roots: &[PathBuf]) -> Vec { + scan_files(analyzer, &collect_scan_paths(roots), &mut |_, _| {}) +} diff --git a/crates/server/tests/cli.rs b/crates/server/tests/cli.rs new file mode 100644 index 0000000..0e3051c --- /dev/null +++ b/crates/server/tests/cli.rs @@ -0,0 +1,216 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct TempDir(PathBuf); + +impl TempDir { + fn new(name: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "zerosyntax-cli-{name}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn run(args: &[&str], stdin: Option<&str>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_zerosyntax-lsp")); + command + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if stdin.is_some() { + command.stdin(Stdio::piped()); + } + let mut child = command.spawn().unwrap(); + if let Some(input) = stdin { + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + } + child.wait_with_output().unwrap() +} + +fn json(output: &Output) -> Vec { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "invalid JSON: {error}; stdout={}", + String::from_utf8_lossy(&output.stdout) + ) + }) +} + +fn write_big(path: &Path, entry: &str, content: &[u8]) { + let mut entry = entry.replace('/', "\\").into_bytes(); + entry.push(0); + let data_offset = 0x10 + 8 + entry.len(); + let archive_size = data_offset + content.len(); + 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(&0u32.to_be_bytes()); + bytes.extend_from_slice(&(data_offset as u32).to_be_bytes()); + bytes.extend_from_slice(&(content.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&entry); + bytes.extend_from_slice(content); + fs::write(path, bytes).unwrap(); +} + +#[test] +fn stdin_json_positions_and_failure_thresholds() { + let error = run( + &["check", "-", "--json"], + Some("Weapon X\n ClipSize = é\nEnd\n"), + ); + assert_eq!(error.status.code(), Some(1)); + let diagnostics = json(&error); + let bad_number = diagnostics + .iter() + .find(|item| item["code"] == "bad-number") + .unwrap(); + assert_eq!(bad_number["file"], ""); + assert_eq!(bad_number["range"]["start"]["line"], 2); + assert_eq!(bad_number["range"]["start"]["column"], 14); + assert_eq!(bad_number["range"]["end"]["column"], 15); + + let warning_src = "Weapon X\n PrimaryDamg = 1\nEnd\n"; + assert_eq!( + run(&["check", "--json", "-"], Some(warning_src)) + .status + .code(), + Some(0) + ); + assert_eq!( + run( + &["check", "--json", "--fail-on", "warning", "-"], + Some(warning_src) + ) + .status + .code(), + Some(1) + ); + + let hint_src = "; zerosyntax-disable: made-up-code\nWeapon X\nEnd\n"; + assert_eq!( + run( + &["check", "--json", "--fail-on", "hint", "-"], + Some(hint_src) + ) + .status + .code(), + Some(1) + ); +} + +#[test] +fn directory_targets_are_indexed_together_and_deduplicated() { + let dir = TempDir::new("workspace"); + let definitions = dir.path().join("definitions.ini"); + let object = dir.path().join("object.ini"); + fs::write(&definitions, "Upgrade Upgrade_Test\n Bogus = 1\nEnd\n").unwrap(); + fs::write( + &object, + "Object Tank\n Behavior = WeaponSetUpgrade ModuleTag_01\n TriggeredBy = Upgrade_Test\n Bogus = 1\n End\nEnd\n", + ) + .unwrap(); + + let dir_arg = dir.path().to_str().unwrap(); + let object_arg = object.to_str().unwrap(); + let output = run(&["check", "--json", dir_arg, object_arg], None); + assert_eq!(output.status.code(), Some(0)); + let diagnostics = json(&output); + assert!(!diagnostics + .iter() + .any(|item| item["code"] == "unresolved-reference")); + let unknown_fields: Vec<_> = diagnostics + .iter() + .filter(|item| item["code"] == "unknown-field") + .collect(); + assert_eq!( + unknown_fields.len(), + 2, + "overlapping targets must only be diagnosed once" + ); + assert!(unknown_fields[0]["file"].as_str() < unknown_fields[1]["file"].as_str()); +} + +#[test] +fn directory_and_big_base_roots_resolve_without_reporting_base_diagnostics() { + let base_dir = TempDir::new("base-dir"); + fs::write( + base_dir.path().join("base.ini"), + "Object Tank\n Bogus = 1\nEnd\n", + ) + .unwrap(); + let archive_dir = TempDir::new("base-big"); + let archive = archive_dir.path().join("base.big"); + write_big( + &archive, + "Data/INI/base.ini", + b"Object Tank\n Bogus = 1\nEnd\n", + ); + + for base in [base_dir.path(), archive.as_path()] { + let output = run( + &[ + "check", + "--json", + "--base-root", + base.to_str().unwrap(), + "--stdin-filename", + "map.ini", + "-", + ], + Some("Object Tank\nEnd\n"), + ); + assert_eq!(output.status.code(), Some(0)); + let diagnostics = json(&output); + assert!(diagnostics.iter().any(|item| item["code"] == "overrides")); + assert!(!diagnostics + .iter() + .any(|item| item["code"] == "duplicate-definition")); + assert!(diagnostics.iter().all(|item| item["file"] == "map.ini")); + } +} + +#[test] +fn human_output_and_usage_failures_are_stable() { + let output = run(&["check", "-"], Some("Weapon X\n ClipSize = lots\nEnd\n")); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(":2:14: error[bad-number]:"), + "{stdout}" + ); + assert!(stdout.contains("1 error(s), 0 warning(s), 0 hint(s)")); + + assert_eq!(run(&["check"], None).status.code(), Some(2)); + assert_eq!( + run(&["check", "definitely-does-not-exist.ini"], None) + .status + .code(), + Some(2) + ); +} diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index daa11cc..8e9319e 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -60,8 +60,10 @@ def main() -> int: (workspace / "Images.INI").write_text("MappedImage TestScanImage\nEnd\n") root_uri = workspace.as_uri() + # vscode-languageclient appends this conventional transport flag. The + # second server below remains a bare invocation so both entry paths stay pinned. proc = subprocess.Popen( - [exe], + [exe, "--stdio"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, diff --git a/docs/language-server.md b/docs/language-server.md index 8248f40..6a3bc82 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -15,6 +15,55 @@ over stdio. The server writes protocol messages to stdout, so clients must launch it using stdio rather than a TCP port. +## Command-line diagnostics + +Use the `check` subcommand to run the same parser, schema, workspace index, and +diagnostics without an LSP client: + +```sh +zerosyntax-lsp check Data/INI +zerosyntax-lsp check map.ini --base-root "C:/Games/Zero Hour" +zerosyntax-lsp check --fail-on warning Data/INI +zerosyntax-lsp check --json --stdin-filename map.ini - < generated.ini +``` + +The positional targets are `.ini` files, recursively scanned directories, or +one `-` for stdin. At least one target is required. All selected targets are +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, +`--stdin-filename` supplies the displayed/indexed name and enables `map.ini` or +`solo.ini` override semantics; it defaults to ``. + +Human output uses compiler-style records followed by a summary: + +```text +Data/INI/Weapon.ini:12:14: error[bad-number]: expected an integer, found `lots` +1 error(s), 0 warning(s), 0 hint(s) +``` + +`--json` writes a stable array to stdout. Each record has `file`, `range`, +`severity`, `code`, and `message`. Range lines and Unicode-scalar columns are +1-based; the end position is exclusive. + +```json +[{"file":"map.ini","range":{"start":{"line":2,"column":14},"end":{"line":2,"column":18}},"severity":"error","code":"bad-number","message":"expected an integer, found `lots`"}] +``` + +Exit codes are: + +- `0`: no diagnostic meets the failure threshold. +- `1`: at least one diagnostic meets it. +- `2`: invalid arguments, inaccessible/unsupported inputs, or an internal + failure. + +The default threshold is `error`. Select `--fail-on warning` or +`--fail-on hint` for stricter CI. Diagnostics are still written to stdout when +the command exits 1; operational errors go to stderr. + ## Client configuration Configure your LSP client with: From e84de1dd18d4c71cf451b3da09ad9326b8dd429e Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:08:46 +0200 Subject: [PATCH 2/6] Merge pull request #47 from ViTeXFTW/feat-custom-schema --- crates/server/src/backend.rs | 130 ++++++++++++++---- .../server/tests/fixtures/custom-schema.json | 16 +++ docs/language-server.md | 3 + editors/vscode/README.md | 1 + editors/vscode/package.json | 11 ++ editors/vscode/src/extension.ts | 13 ++ 6 files changed, 149 insertions(+), 25 deletions(-) create mode 100644 crates/server/tests/fixtures/custom-schema.json diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index 2670f02..6bb0926 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -45,7 +45,8 @@ struct DocumentState { pub struct Backend { client: Client, - analyzer: Arc, + analyzer: RwLock>, + schema_error: Mutex>, /// Open documents, keyed by URI. docs: DashMap, /// Read-only documents synthesized from configured `.big` archives. @@ -81,6 +82,24 @@ pub struct Backend { semantic_result_id: std::sync::atomic::AtomicU64, } +fn load_schema(path: &str) -> std::result::Result { + let text = + std::fs::read_to_string(path).map_err(|e| format!("could not read `{path}`: {e}"))?; + let schema = zerosyntax_schema::Schema::from_json(&text) + .map_err(|e| format!("could not parse `{path}`: {e}"))?; + Ok(Analyzer::new(schema)) +} + +fn load_schema_or_embedded(path: &str) -> (Analyzer, Option) { + match load_schema(path) { + Ok(analyzer) => (analyzer, None), + Err(error) => ( + Analyzer::embedded(), + Some(format!("ZeroSyntax: {error}; using the built-in schema.")), + ), + } +} + #[derive(Deserialize)] pub struct VirtualFileParams { uri: String, @@ -114,7 +133,8 @@ impl Backend { pub fn new(client: Client) -> Self { Backend { client, - analyzer: Arc::new(Analyzer::embedded()), + analyzer: RwLock::new(Arc::new(Analyzer::embedded())), + schema_error: Mutex::new(None), docs: DashMap::new(), virtual_files: DashMap::new(), index: RwLock::new(WorkspaceIndex::new()), @@ -136,6 +156,13 @@ impl Backend { self.encoding.get().copied().unwrap_or_default() } + fn analyzer(&self) -> Arc { + self.analyzer + .read() + .expect("analyzer lock poisoned") + .clone() + } + fn format_enabled(&self) -> bool { self.format_enabled.get().copied().unwrap_or(false) } @@ -165,9 +192,10 @@ impl Backend { // `set_file` bumps the index generation only when definition *names* // changed, so ordinary keystrokes keep diagnostics caches warm. // Reference sites never bump it. - let defs = definitions_in(&self.analyzer, &parse, uri.as_str()); - let refs = references_in(&self.analyzer, &parse); - let tags = module_tags_in(&self.analyzer, &parse); + 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 str_keys = load_sibling_str_keys(uri); if let Ok(mut idx) = self.index.write() { idx.set_file(uri.as_str(), defs); @@ -180,7 +208,7 @@ impl Backend { let lsp_diags: Vec = { let idx = self.index.read().ok(); let diags = diagnostics::diagnose_with_cache( - &self.analyzer, + &analyzer, &parse, idx.as_deref(), Some(uri.as_str()), @@ -253,7 +281,7 @@ impl Backend { .lock() .map(|r| r.clone()) .unwrap_or_default(); - let analyzer = self.analyzer.clone(); + 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)>(); @@ -434,8 +462,8 @@ impl Backend { fn symbol_at(&self, uri: &Url, pos: Position) -> Option { let (rope, parse) = self.doc(uri)?; let offset = convert::position_to_offset(&rope, pos, self.enc()); - reference_at(&self.analyzer, &parse, offset) - .or_else(|| definition_at(&self.analyzer, &parse, offset)) + let analyzer = self.analyzer(); + reference_at(&analyzer, &parse, offset).or_else(|| definition_at(&analyzer, &parse, offset)) } /// Convert `(file uri, span)` pairs to LSP locations, reading each file's @@ -486,7 +514,8 @@ impl LanguageServer for Backend { // Editor-facing settings arrive as `initializationOptions`; a change // requires a client restart (the VS Code extension does this // automatically). Shape: - // `{ "format": {"enable": bool}, "baseIniRoots": ["dir-or-big", ...], + // `{ "format": {"enable": bool}, "schemaPath": "schema.json", + // "baseIniRoots": ["dir-or-big", ...], // "clientBaseIniHint": bool }`. let format_enabled = params .initialization_options @@ -497,6 +526,22 @@ impl LanguageServer for Backend { .unwrap_or(false); let _ = self.format_enabled.set(format_enabled); + 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 let Ok(mut current) = self.analyzer.write() { + *current = Arc::new(analyzer); + } + if let Ok(mut current) = self.schema_error.lock() { + *current = error; + } + } + let base_roots = params .initialization_options .as_ref() @@ -589,6 +634,9 @@ impl LanguageServer for Backend { } async fn initialized(&self, _: InitializedParams) { + 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; // Re-publish diagnostics for any already-open docs now that the index // is populated (so cross-file references resolve). The cached parse is @@ -623,7 +671,7 @@ impl LanguageServer for Backend { let uri = canonical_uri(params.text_document.uri); let text: Arc = params.text_document.text.into(); let rope = Rope::from_str(&text); - let parse = Arc::new(self.analyzer.parse(&text)); + let parse = Arc::new(self.analyzer().parse(&text)); let version = params.text_document.version; self.docs.insert( uri.clone(), @@ -671,7 +719,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(self.analyzer().parse(&entry.text)); entry.version = version; } else { // Each change applies to the text produced by the previous @@ -691,7 +739,7 @@ impl LanguageServer for Backend { new_len: change.text.len(), }; let (parse, _strategy) = - self.analyzer + self.analyzer() .reparse(&entry.parse, &entry.text, &new_text, edit); entry.parse = Arc::new(parse); entry.text = new_text; @@ -700,7 +748,7 @@ 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(self.analyzer().parse(&entry.text)); } } } @@ -726,7 +774,7 @@ impl LanguageServer for Backend { let idx = self.index.read().ok(); let snippets = self.snippet_support.get().copied().unwrap_or(false); let items: Vec = completion::complete( - &self.analyzer, + &self.analyzer(), &parse, offset, idx.as_deref(), @@ -746,7 +794,7 @@ impl LanguageServer for Backend { let Some((rope, parse)) = self.doc(&uri) else { return Ok(None); }; - let tokens = semantic::semantic_tokens(&self.analyzer, &parse); + let tokens = semantic::semantic_tokens(&self.analyzer(), &parse); let data = convert::to_lsp_semantic_tokens(&rope, &tokens, self.enc()); let id = self.next_semantic_id(); if let Some(mut doc) = self.docs.get_mut(&uri) { @@ -766,7 +814,7 @@ impl LanguageServer for Backend { let Some((rope, parse)) = self.doc(&uri) else { return Ok(None); }; - let tokens = semantic::semantic_tokens(&self.analyzer, &parse); + let tokens = semantic::semantic_tokens(&self.analyzer(), &parse); let data = convert::to_lsp_semantic_tokens(&rope, &tokens, self.enc()); let id = self.next_semantic_id(); let previous = self @@ -848,14 +896,14 @@ impl LanguageServer for Backend { let fixes = { let idx = self.index.read().ok(); let diags = diagnostics::diagnose_with_cache( - &self.analyzer, + &self.analyzer(), &parse, idx.as_deref(), Some(uri.as_str()), &mut cache, ); let mut f = actions::fixes( - &self.analyzer, + &self.analyzer(), &parse, &text, range_span, @@ -865,7 +913,7 @@ impl LanguageServer for Backend { // Origin-copy fix: requires file I/O, so computed here in the server. if let Some(idx) = idx.as_deref() { f.extend(origin_copy_fixes( - &self.analyzer, + &self.analyzer(), &parse, &text, range_span, @@ -917,7 +965,7 @@ impl LanguageServer for Backend { let start = convert::position_to_offset(&rope, params.range.start, enc); let end = convert::position_to_offset(&rope, params.range.end, enc); let tokens = semantic::semantic_tokens_range( - &self.analyzer, + &self.analyzer(), &parse, zerosyntax_analysis::Span::new(start, end), ); @@ -939,7 +987,7 @@ impl LanguageServer for Backend { }; let enc = self.enc(); let offset = convert::position_to_offset(&rope, pos, enc); - let Some(reference) = reference_at(&self.analyzer, &parse, offset) else { + let Some(reference) = reference_at(&self.analyzer(), &parse, offset) else { return Ok(None); }; @@ -1159,13 +1207,13 @@ impl LanguageServer for Backend { }; let enc = self.enc(); let offset = convert::position_to_offset(&rope, pos, enc); - let Some(info) = hover_at(&self.analyzer, &parse, offset) else { + let analyzer = self.analyzer(); + let Some(info) = hover_at(&analyzer, &parse, offset) else { return Ok(None); }; let (markdown, span) = match info { HoverInfo::Block { name, span } => { - let doc = self - .analyzer + let doc = analyzer .block(&name) .and_then(|b| b.doc.clone()) .unwrap_or_else(|| format!("Top-level block `{name}`.")); @@ -1302,6 +1350,38 @@ fn origin_copy_fixes( mod tests { use super::*; + #[test] + fn custom_schema_changes_analysis() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/custom-schema.json"); + let analyzer = load_schema(path.to_str().unwrap()).unwrap(); + let parse = analyzer.parse("TestBlock Test\n CustomOnly = Yes\nEnd\n"); + let codes: Vec<_> = diagnostics::diagnose(&analyzer, &parse, None, None) + .into_iter() + .map(|diagnostic| diagnostic.code) + .collect(); + assert!(codes.is_empty(), "{codes:?}"); + + let embedded = Analyzer::embedded(); + let parse = embedded.parse("TestBlock Test\n CustomOnly = Yes\nEnd\n"); + assert!(diagnostics::diagnose(&embedded, &parse, None, None) + .iter() + .any(|diagnostic| diagnostic.code == "unknown-block")); + } + + #[test] + fn invalid_custom_schema_falls_back_to_embedded() { + let path = std::env::temp_dir().join(format!( + "zerosyntax-invalid-schema-{}.json", + std::process::id() + )); + std::fs::write(&path, "not json").unwrap(); + let (analyzer, warning) = load_schema_or_embedded(path.to_str().unwrap()); + assert!(analyzer.block("Object").is_some()); + assert!(warning.is_some_and(|warning| warning.contains("using the built-in schema"))); + let _ = std::fs::remove_file(path); + } + #[test] fn canonical_uri_pass_through_non_file() { let u = Url::parse("untitled:///buffer").unwrap(); diff --git a/crates/server/tests/fixtures/custom-schema.json b/crates/server/tests/fixtures/custom-schema.json new file mode 100644 index 0000000..ec77700 --- /dev/null +++ b/crates/server/tests/fixtures/custom-schema.json @@ -0,0 +1,16 @@ +{ + "format_version": 1, + "blocks": [ + { + "name": "TestBlock", + "fields": [ + { + "name": "CustomOnly", + "value_type": { "kind": "bool" } + } + ] + } + ], + "modules": [], + "value_sets": [] +} diff --git a/docs/language-server.md b/docs/language-server.md index 6a3bc82..f7ff767 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -84,6 +84,7 @@ symbols. ```json { "format": { "enable": false }, + "schemaPath": "C:/Mods/MyMod/schema.json", "baseIniRoots": [ "C:/Games/Zero Hour", "C:/Mods/MyMod/Data/INI", @@ -94,6 +95,8 @@ symbols. - `format.enable` controls whether the server advertises document formatting. It defaults to `false`. +- `schemaPath` points to a custom schema JSON file. Unreadable or invalid files + produce a warning and fall back to the built-in schema. - `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/README.md b/editors/vscode/README.md index 9919f8f..c2e2532 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -36,6 +36,7 @@ checks. | Setting | Default | Purpose | | --- | --- | --- | | `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for map and model checks. | +| `zerosyntax.schema.path` | empty | Custom schema JSON; invalid files fall back to the built-in schema. | | `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.trace.server` | `off` | Logs LSP traffic for troubleshooting. | diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 0931a33..bdd8d39 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -22,6 +22,12 @@ "onLanguage:generals-ini" ], "contributes": { + "commands": [ + { + "command": "zerosyntax.selectSchema", + "title": "ZeroSyntax: Select Custom Schema" + } + ], "languages": [ { "id": "generals-ini", @@ -64,6 +70,11 @@ }, "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." }, + "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." + }, "zerosyntax.trace.server": { "type": "string", "enum": [ diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 41c2c67..f14af68 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -39,6 +39,7 @@ export function activate(context: vscode.ExtensionContext) { enable: setting("format.enable", false), }, baseIniRoots: setting("baseIniRoots", []), + schemaPath: setting("schema.path", ""), clientBaseIniHint: true, }), }; @@ -65,6 +66,18 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push( + vscode.commands.registerCommand("zerosyntax.selectSchema", async () => { + const selected = await vscode.window.showOpenDialog({ + canSelectMany: false, + filters: { JSON: ["json"] }, + openLabel: "Use schema", + }); + if (selected?.[0]) { + await vscode.workspace + .getConfiguration("zerosyntax") + .update("schema.path", selected[0].fsPath, vscode.ConfigurationTarget.Workspace); + } + }), vscode.workspace.onDidOpenTextDocument((document) => { void maybeShowBaseIniRootsHint(document); }) From d69bb0a289f90beb4fcbd843d2bfaa25da91f3a1 Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:10:24 +0200 Subject: [PATCH 3/6] Add cross-block model member validation (OCL transport bones, subobjects, and inherited Object models) (#48) --- README.md | 4 + crates/analysis/src/completion.rs | 42 +++++- crates/analysis/src/diagnostics.rs | 122 ++++++++++++++++- crates/analysis/src/index.rs | 209 +++++++++++++++++++++++++++++ crates/analysis/src/model.rs | 56 +++++++- crates/schema/schema.json | 29 ++-- crates/schema/src/lib.rs | 11 ++ crates/server/src/backend.rs | 44 +++++- crates/server/src/cli.rs | 10 +- crates/server/src/scan.rs | 22 ++- crates/syntax/src/ast.rs | 6 + docs/language-server.md | 4 + editors/vscode/README.md | 1 + editors/vscode/package.json | 6 + editors/vscode/src/extension.ts | 3 + 15 files changed, 535 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index f4e136f..d41bfee 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ For complete `map.ini` and `solo.ini` diagnostics, set `.big` archives that load before the map. The same setting also enables W3D model and bone completion and validation. +`zerosyntax.analysis.modelMemberStrictness` controls bone/subobject warnings: +`off`, `compatible` (the default; present in any applicable model), or `strict` +(present in every applicable model). + ```json { "zerosyntax.baseIniRoots": [ diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index 3e03b78..877e78d 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -11,7 +11,8 @@ use zerosyntax_syntax::ast::{Block, Field, Module}; use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode}; use crate::model::{ - is_model_asset_type, is_model_member_type, model_member_ini_name, models_in_scope, scope_schema, + is_model_asset_type, is_model_member_type, model_member_ini_name, models_for_source, + scope_schema, }; use crate::{Analyzer, WorkspaceIndex}; @@ -325,9 +326,14 @@ fn field_value_completions( let mut base = { let scope = scope_schema(analyzer, scope_node); if let Some(f) = scope.field(key) { - if let Some(asset_completions) = - model_asset_completions(analyzer, scope_node, &f.value_type, value_index, index) - { + if let Some(asset_completions) = model_asset_completions( + analyzer, + scope_node, + &f.value_type, + value_index, + index, + f.model_source.as_ref(), + ) { asset_completions } else { completions_for_type( @@ -362,6 +368,7 @@ fn model_asset_completions( ty: &ValueType, value_index: usize, index: Option<&WorkspaceIndex>, + source: Option<&zerosyntax_schema::ModelSource>, ) -> Option> { let index = index?; if !index.has_model_assets() { @@ -385,7 +392,7 @@ fn model_asset_completions( return None; } let mut seen = std::collections::HashSet::new(); - let out = models_in_scope(analyzer, scope_node) + let out = models_for_source(analyzer, scope_node, source, index) .into_iter() .flat_map(|model| { index @@ -904,6 +911,31 @@ End assert!(out.contains(&"Good".to_string()), "{out:?}"); } + #[test] + fn ocl_member_completions_use_transport_models() { + let a = Analyzer::embedded(); + let mut index = WorkspaceIndex::new(); + index.set_file_models( + "a10.w3d", + vec![crate::index::ModelAsset { + name: "A10".into(), + members: vec!["WeaponA01".into()], + }], + ); + index.set_file_object_models( + "objects.ini", + vec![("AmericaJetA10Thunderbolt".into(), vec!["A10".into()])], + ); + let src = "ObjectCreationList Strike\n DeliverPayload\n Transport = AmericaJetA10Thunderbolt\n VisibleDropBoneBaseName = \n End\nEnd\n"; + let offset = + src.find("VisibleDropBoneBaseName = ").unwrap() + "VisibleDropBoneBaseName = ".len(); + let out = complete(&a, &a.parse(src), offset as u32, Some(&index), None) + .into_iter() + .map(|item| item.label) + .collect::>(); + assert!(out.contains(&"WeaponA".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 0c46ee5..5a4f1bf 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -20,8 +20,9 @@ use zerosyntax_schema::{RefKind, ValueType}; use zerosyntax_syntax::ast::{Block, Field, Module}; use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode, SyntaxToken}; +use crate::index::ModelMemberStrictness; use crate::model::{ - is_model_asset_type, is_model_member_type, model_member_matches, models_in_scope, + is_model_asset_type, is_model_member_type, model_member_matches, models_for_source, module_fits_slot, scope_schema, ScopeSchema, }; use crate::{Analyzer, Span, WorkspaceIndex}; @@ -781,12 +782,22 @@ impl<'a> Ctx<'a> { match &schema_field.value_type { ValueType::TokenList { tokens: specs } => { for (spec, tok) in specs.iter().zip(tokens.iter()) { - self.validate_model_asset_token(spec, tok, scope_node); + self.validate_model_asset_token( + spec, + tok, + scope_node, + schema_field.model_source.as_ref(), + ); } } ty => { if let Some(tok) = tokens.first() { - self.validate_model_asset_token(ty, tok, scope_node); + self.validate_model_asset_token( + ty, + tok, + scope_node, + schema_field.model_source.as_ref(), + ); } } } @@ -797,6 +808,7 @@ impl<'a> Ctx<'a> { ty: &ValueType, tok: &SyntaxToken, scope_node: &SyntaxNode, + source: Option<&zerosyntax_schema::ModelSource>, ) { let Some(index) = self.index else { return }; let value = unquote(tok.text()); @@ -816,11 +828,15 @@ impl<'a> Ctx<'a> { if !is_model_member_type(ty) { return; } - let models = models_in_scope(self.analyzer, scope_node); + if index.model_member_strictness() == ModelMemberStrictness::Off { + return; + } + let models = models_for_source(self.analyzer, scope_node, source, index); if models.is_empty() { return; } let mut checked_any_model = false; + let mut missing = Vec::new(); for model in models { if !index.is_model_asset(&model) { continue; @@ -830,14 +846,25 @@ impl<'a> Ctx<'a> { .model_members(&model) .any(|member| model_member_matches(member, value)) { - return; + if index.model_member_strictness() == ModelMemberStrictness::Compatible { + return; + } + } else { + missing.push(model); } } - if checked_any_model { + if checked_any_model && !missing.is_empty() { self.warning( tok, "unknown-model-member", - format!("`{value}` is not a known W3D model bone or subobject"), + format!( + "`{value}` is not a known W3D model bone or subobject{}", + if index.model_member_strictness() == ModelMemberStrictness::Strict { + format!(" in {}", missing.join(", ")) + } else { + String::new() + } + ), ); } } @@ -2028,4 +2055,85 @@ End "{diags:?}" ); } + + #[test] + fn ocl_members_resolve_through_transport_object() { + let a = Analyzer::embedded(); + let mut index = WorkspaceIndex::new(); + index.set_file_models( + "models/A10.w3d", + vec![crate::index::ModelAsset { + name: "A10".into(), + members: vec!["WeaponA01".into(), "Missile01".into()], + }], + ); + let object = "Object AmericaJetA10Thunderbolt\n Draw = W3DModelDraw ModuleTag_Draw\n DefaultConditionState\n Model = A10\n End\n End\nEnd\n"; + let object_parse = a.parse(object); + index.set_file( + "objects.ini", + crate::index::definitions_in(&a, &object_parse, "objects.ini"), + ); + index.set_file_object_models( + "objects.ini", + crate::index::object_models_in(&a, &object_parse), + ); + + let src = "ObjectCreationList Strike\n DeliverPayload\n Transport = AmericaJetA10Thunderbolt\n VisibleDropBoneBaseName = WeaponA\n VisibleSubObjectBaseName = Missing\n End\nEnd\n"; + let parse = a.parse(src); + let diags = diagnose(&a, &parse, Some(&index), Some("ocl.ini")); + assert!( + !diags.iter().any(|d| d.code == "unknown-model-member" + && &src[d.span.start as usize..d.span.end as usize] == "WeaponA"), + "{diags:?}" + ); + assert!( + diags.iter().any(|d| d.code == "unknown-model-member" + && &src[d.span.start as usize..d.span.end as usize] == "Missing"), + "{diags:?}" + ); + + let missing = src.replace("AmericaJetA10Thunderbolt", "MissingTransport"); + let diags = diagnose(&a, &a.parse(&missing), Some(&index), Some("ocl.ini")); + assert!( + diags.iter().any(|d| d.code == "unresolved-reference"), + "{diags:?}" + ); + assert!( + !diags.iter().any(|d| d.code == "unknown-model-member"), + "{diags:?}" + ); + } + + #[test] + fn model_member_strictness_supports_off_compatible_and_strict() { + let a = Analyzer::embedded(); + let mut index = WorkspaceIndex::new(); + index.set_file_models( + "a.w3d", + vec![crate::index::ModelAsset { + name: "A".into(), + members: vec!["Bone01".into()], + }], + ); + index.set_file_models( + "b.w3d", + vec![crate::index::ModelAsset { + name: "B".into(), + members: vec![], + }], + ); + let src = "Object Tank\n Draw = W3DModelDraw Tag\n DefaultConditionState\n Model = A\n Model = B\n HideSubObject = Bone\n End\n End\nEnd\n"; + let parse = a.parse(src); + assert!(!diagnose(&a, &parse, Some(&index), None) + .iter() + .any(|d| d.code == "unknown-model-member")); + index.set_model_member_strictness(ModelMemberStrictness::Strict); + assert!(diagnose(&a, &parse, Some(&index), None) + .iter() + .any(|d| d.code == "unknown-model-member")); + index.set_model_member_strictness(ModelMemberStrictness::Off); + assert!(!diagnose(&a, &parse, Some(&index), None) + .iter() + .any(|d| d.code == "unknown-model-member")); + } } diff --git a/crates/analysis/src/index.rs b/crates/analysis/src/index.rs index 4dbc964..780b6e7 100644 --- a/crates/analysis/src/index.rs +++ b/crates/analysis/src/index.rs @@ -44,6 +44,14 @@ pub struct ReferenceSite { pub span: Span, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ModelMemberStrictness { + Off, + #[default] + Compatible, + Strict, +} + /// A definition name's entry: display casing plus all its locations. struct NameEntry { /// The name as first written (for completion display). @@ -87,6 +95,11 @@ pub struct WorkspaceIndex { model_assets: HashMap>, /// Reverse map for removing/replacing models contributed by one asset file. file_models: HashMap>, + object_models: HashMap)>>, + file_object_models: HashMap)>>, + object_parents: HashMap>, + file_object_parents: HashMap>, + model_member_strictness: ModelMemberStrictness, } impl WorkspaceIndex { @@ -101,6 +114,17 @@ impl WorkspaceIndex { self.generation } + pub fn model_member_strictness(&self) -> ModelMemberStrictness { + self.model_member_strictness + } + + pub fn set_model_member_strictness(&mut self, value: ModelMemberStrictness) { + if self.model_member_strictness != value { + self.model_member_strictness = value; + self.generation += 1; + } + } + /// Replace all definitions contributed by `file` with `defs`. pub fn set_file(&mut self, file: &str, defs: Vec) { let names: Vec<(RefKind, String)> = defs @@ -156,12 +180,22 @@ impl WorkspaceIndex { pub fn remove_file(&mut self, file: &str) { if self.files.get(file).is_some_and(|v| !v.is_empty()) || self.file_models.get(file).is_some_and(|v| !v.is_empty()) + || self + .file_object_models + .get(file) + .is_some_and(|v| !v.is_empty()) + || self + .file_object_parents + .get(file) + .is_some_and(|v| !v.is_empty()) { self.generation += 1; } self.remove_entries(file); self.remove_site_entries(file); self.remove_model_entries(file); + self.remove_object_model_entries(file); + self.remove_object_parent_entries(file); } fn remove_site_entries(&mut self, file: &str) { @@ -231,6 +265,97 @@ impl WorkspaceIndex { } } + 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); + if changed { + self.generation += 1; + } + self.remove_object_model_entries(file); + for (name, models) in &objects { + self.object_models + .entry(name.to_ascii_lowercase()) + .or_default() + .push((file.to_string(), models.clone())); + } + if !normalized.is_empty() { + self.file_object_models.insert(file.to_string(), normalized); + } + } + + fn remove_object_model_entries(&mut self, file: &str) { + if let Some(objects) = self.file_object_models.remove(file) { + for (name, _) in objects { + if let Some(entries) = self.object_models.get_mut(&name) { + entries.retain(|(source, _)| source != file); + if entries.is_empty() { + self.object_models.remove(&name); + } + } + } + } + } + + pub fn set_file_object_parents(&mut self, file: &str, parents: Vec<(String, String)>) { + let normalized = parents + .iter() + .map(|(child, parent)| (child.to_ascii_lowercase(), parent.to_ascii_lowercase())) + .collect::>(); + if self.file_object_parents.get(file) != Some(&normalized) { + self.generation += 1; + } + self.remove_object_parent_entries(file); + for (child, parent) in &parents { + self.object_parents + .entry(child.to_ascii_lowercase()) + .or_default() + .push((file.to_string(), parent.clone())); + } + if !normalized.is_empty() { + self.file_object_parents + .insert(file.to_string(), normalized); + } + } + + fn remove_object_parent_entries(&mut self, file: &str) { + if let Some(parents) = self.file_object_parents.remove(file) { + for (child, _) in parents { + if let Some(entries) = self.object_parents.get_mut(&child) { + entries.retain(|(source, _)| source != file); + if entries.is_empty() { + self.object_parents.remove(&child); + } + } + } + } + } + + pub fn models_for_object<'a>(&'a self, name: &str) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut pending = vec![name.to_ascii_lowercase()]; + let mut seen = std::collections::HashSet::new(); + while let Some(object) = pending.pop() { + if !seen.insert(object.clone()) { + continue; + } + if let Some(entries) = self.object_models.get(&object) { + out.extend( + entries + .iter() + .flat_map(|(_, models)| models.iter().map(String::as_str)), + ); + } + if let Some(parents) = self.object_parents.get(&object) { + pending.extend( + parents + .iter() + .map(|(_, parent)| parent.to_ascii_lowercase()), + ); + } + } + out + } + /// Replace module-tag entries contributed by `file`. /// Called alongside `set_file` so RemoveModule completions stay current. pub fn set_file_tags(&mut self, file: &str, tags: Vec<(String, String)>) { @@ -399,6 +524,80 @@ fn normalized_model_assets(models: &[ModelAsset]) -> Vec<(String, Vec)> out } +fn normalize_object_models(objects: &[(String, Vec)]) -> Vec<(String, Vec)> { + let mut out = objects + .iter() + .map(|(name, models)| { + let mut models = models + .iter() + .map(|m| m.to_ascii_lowercase()) + .collect::>(); + models.sort(); + models.dedup(); + (name.to_ascii_lowercase(), models) + }) + .collect::>(); + out.sort(); + out +} + +/// Collect the W3D models declared below every Object definition. +pub fn object_models_in(analyzer: &Analyzer, parse: &Parse) -> Vec<(String, Vec)> { + parse + .syntax() + .children() + .filter_map(|node| { + let block = Block(node.clone()); + if !block.keyword()?.text().eq_ignore_ascii_case("Object") { + return None; + } + let name = block.name()?.text().to_string(); + let mut models = Vec::new(); + collect_object_models(analyzer, &node, &mut models); + dedup_case_insensitive(&mut models); + Some((name, models)) + }) + .collect() +} + +pub fn object_parents_in(parse: &Parse) -> Vec<(String, String)> { + parse + .syntax() + .children() + .filter_map(|node| { + let block = Block(node); + if !block.keyword()?.text().eq_ignore_ascii_case("Object") { + return None; + } + Some(( + block.name()?.text().to_string(), + block.parent_name()?.text().to_string(), + )) + }) + .collect() +} + +fn collect_object_models(analyzer: &Analyzer, node: &SyntaxNode, out: &mut Vec) { + let scope = scope_schema(analyzer, node); + for child in node.children() { + match child.kind() { + SyntaxKind::FIELD => { + let field = Field(child); + let Some(schema_field) = field.key().and_then(|key| scope.field(key.text())) else { + continue; + }; + if matches!(schema_field.value_type, ValueType::W3dModel) { + if let Some(value) = field.value_tokens().first() { + out.push(value.text().trim_matches('"').to_string()); + } + } + } + SyntaxKind::BLOCK | SyntaxKind::MODULE => collect_object_models(analyzer, &child, out), + _ => {} + } + } +} + /// Collect every reference site in a parsed document: each value token of a /// `Reference`/`ReferenceList`-typed field (including reference elements of /// `token_list` fields). Null sentinels (`None`, audio `NoSound`) and engine @@ -655,6 +854,16 @@ mod tests { assert!(!idx.is_referenced(RefKind::Upgrade, "Upgrade_A")); } + #[test] + fn object_models_follow_inheritance() { + let a = Analyzer::embedded(); + let parse = a.parse("Object Parent\n Draw = W3DModelDraw Tag\n DefaultConditionState\n Model = ParentModel\n End\n End\nEnd\nObject Child Parent\nEnd\n"); + let mut idx = WorkspaceIndex::new(); + idx.set_file_object_models("objects.ini", object_models_in(&a, &parse)); + idx.set_file_object_parents("objects.ini", object_parents_in(&parse)); + assert_eq!(idx.models_for_object("child"), vec!["ParentModel"]); + } + #[test] fn quoted_prefixed_reference_site_span_excludes_quotes_and_prefix() { let a = Analyzer::embedded(); diff --git a/crates/analysis/src/model.rs b/crates/analysis/src/model.rs index 53eaf3c..99140ff 100644 --- a/crates/analysis/src/model.rs +++ b/crates/analysis/src/model.rs @@ -1,8 +1,10 @@ //! Bridges the syntax tree to the schema: given a scope node (a `BLOCK` or //! `MODULE`), determine which schema entity it is and look up fields / slots. -use zerosyntax_schema::{BlockType, Field, ModuleSlot, ModuleType, SubBlock, ValueType}; -use zerosyntax_syntax::ast::Field as AstField; +use zerosyntax_schema::{ + BlockType, Field, ModelSource, ModuleSlot, ModuleType, SubBlock, ValueType, +}; +use zerosyntax_syntax::ast::{Block as AstBlock, Field as AstField}; /// Returns true when `module` implements at least one of the interfaces the /// `slot` accepts. Used to filter completions and validate module placement. @@ -186,6 +188,56 @@ pub(crate) fn models_in_scope(analyzer: &Analyzer, scope_node: &SyntaxNode) -> V } } +pub(crate) fn models_for_source( + analyzer: &Analyzer, + scope_node: &SyntaxNode, + source: Option<&ModelSource>, + index: &crate::WorkspaceIndex, +) -> Vec { + match source { + None | Some(ModelSource::EnclosingObject) => { + let local = models_in_scope(analyzer, scope_node); + if !local.is_empty() { + return local; + } + scope_node + .ancestors() + .find_map(|node| { + let block = AstBlock(node); + block + .keyword() + .filter(|keyword| keyword.text().eq_ignore_ascii_case("Object"))?; + block.name().map(|name| name.text().to_string()) + }) + .map(|object| { + index + .models_for_object(&object) + .into_iter() + .map(str::to_string) + .collect() + }) + .unwrap_or_default() + } + Some(ModelSource::ObjectReferenceField { field }) => { + let Some(object) = scope_node.children().find_map(|child| { + let ini = AstField(child); + ini.key() + .filter(|key| key.text().eq_ignore_ascii_case(field))?; + ini.value_tokens() + .first() + .map(|value| value.text().trim_matches('"').to_string()) + }) else { + return Vec::new(); + }; + index + .models_for_object(&object) + .into_iter() + .map(str::to_string) + .collect() + } + } +} + fn collect_models(analyzer: &Analyzer, node: &SyntaxNode, out: &mut Vec) { let scope = scope_schema(analyzer, node); for child in node.children() { diff --git a/crates/schema/schema.json b/crates/schema/schema.json index 5c66308..6d4fe35 100644 --- a/crates/schema/schema.json +++ b/crates/schema/schema.json @@ -13221,7 +13221,8 @@ { "name": "Transport", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "object" }, "parse_fn": "INI::parseAsciiString" }, @@ -13277,15 +13278,16 @@ { "name": "Payload", "value_type": { - "kind": "unknown", - "parse_fn": "parsePayload" + "kind": "reference", + "ref_kind": "object" }, "parse_fn": "parsePayload" }, { "name": "PutInContainer", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "object" }, "parse_fn": "INI::parseAsciiString" }, @@ -13362,16 +13364,24 @@ { "name": "VisibleDropBoneBaseName", "value_type": { - "kind": "ascii_string" + "kind": "w3d_model_member" }, - "parse_fn": "INI::parseAsciiString" + "parse_fn": "INI::parseAsciiString", + "model_source": { + "kind": "object_reference_field", + "field": "Transport" + } }, { "name": "VisibleSubObjectBaseName", "value_type": { - "kind": "ascii_string" + "kind": "w3d_model_member" }, - "parse_fn": "INI::parseAsciiString" + "parse_fn": "INI::parseAsciiString", + "model_source": { + "kind": "object_reference_field", + "field": "Transport" + } }, { "name": "VisibleNumBones", @@ -13383,7 +13393,8 @@ { "name": "VisiblePayloadTemplateName", "value_type": { - "kind": "ascii_string" + "kind": "reference", + "ref_kind": "object" }, "parse_fn": "INI::parseAsciiString" }, diff --git a/crates/schema/src/lib.rs b/crates/schema/src/lib.rs index 8c46eb7..e6e2384 100644 --- a/crates/schema/src/lib.rs +++ b/crates/schema/src/lib.rs @@ -157,6 +157,16 @@ pub struct Field { pub parse_fn: String, #[serde(default)] pub doc: Option, + #[serde(default)] + pub model_source: Option, +} + +/// Selects the Object whose W3D models own a model-member field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ModelSource { + EnclosingObject, + ObjectReferenceField { field: String }, } /// The type of a field's value, derived from its engine parse function. @@ -394,6 +404,7 @@ mod tests { value_type: ValueType::Real, parse_fn: "parseReal".into(), doc: None, + model_source: None, }], module_slots: vec![], sub_blocks: vec![], diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index 6bb0926..24bbd3d 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -16,7 +16,10 @@ use serde::Deserialize; use tower_lsp::lsp_types::*; use tower_lsp::{jsonrpc::Result, Client, LanguageServer}; use zerosyntax_analysis::diagnostics::DiagnosticsCache; -use zerosyntax_analysis::index::{definitions_in, module_tags_in, references_in, WorkspaceIndex}; +use zerosyntax_analysis::index::{ + definitions_in, module_tags_in, object_models_in, object_parents_in, references_in, + ModelMemberStrictness, WorkspaceIndex, +}; use zerosyntax_analysis::nav::{definition_at, hover_at, reference_at, HoverInfo}; use zerosyntax_analysis::{actions, completion, diagnostics, format, outline, semantic, Analyzer}; use zerosyntax_syntax::{Edit, Parse}; @@ -196,11 +199,15 @@ impl Backend { 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); } @@ -312,7 +319,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, _)| models.is_empty()) .count(); self.base_indexed_count .store(base_ini_count, Ordering::Relaxed); @@ -320,12 +327,12 @@ impl Backend { let ini_total = base_ini_count + scanned .iter() - .filter(|(_, _, _, _, models, _)| models.is_empty()) + .filter(|(_, _, _, _, _, _, models, _)| models.is_empty()) .count(); let model_total: usize = base_scanned .iter() .chain(scanned.iter()) - .map(|(_, _, _, _, models, _)| models.len()) + .map(|(_, _, _, _, _, _, models, _)| models.len()) .sum(); // Don't overwrite index entries for already-open documents with stale // disk content; `initialized` calls `refresh` for each open doc right @@ -339,7 +346,9 @@ impl Backend { let Ok(mut idx) = self.index.write() else { return; }; - for (uri, defs, refs, tags, models, text) in base_scanned.into_iter().chain(scanned) { + for (uri, defs, refs, tags, object_models, object_parents, models, text) in + base_scanned.into_iter().chain(scanned) + { if let Some(text) = text { self.virtual_files.insert(uri.clone(), text); } @@ -347,6 +356,8 @@ impl Backend { 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); } } @@ -515,6 +526,7 @@ impl LanguageServer for Backend { // requires a client restart (the VS Code extension does this // automatically). Shape: // `{ "format": {"enable": bool}, "schemaPath": "schema.json", + // "analysis": {"modelMemberStrictness": "compatible"}, // "baseIniRoots": ["dir-or-big", ...], // "clientBaseIniHint": bool }`. let format_enabled = params @@ -526,6 +538,22 @@ impl LanguageServer for Backend { .unwrap_or(false); let _ = self.format_enabled.set(format_enabled); + 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(); + if let Ok(mut index) = self.index.write() { + index.set_model_member_strictness(model_member_strictness); + } + if let Some(path) = params .initialization_options .as_ref() @@ -1423,7 +1451,7 @@ mod tests { assert!(Url::parse(&scanned[0].0).is_ok()); assert!(scanned[0].1.iter().any(|d| d.name == "BigArchiveObject")); assert_eq!( - scanned[0].5.as_deref(), + scanned[0].7.as_deref(), Some("Object BigArchiveObject\nEnd\n") ); } @@ -1449,10 +1477,12 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); let mut idx = WorkspaceIndex::new(); - for (uri, defs, refs, tags, models, _) in scanned { + for (uri, defs, refs, tags, object_models, object_parents, models, _) 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); } 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 3be9c84..a9336e5 100644 --- a/crates/server/src/cli.rs +++ b/crates/server/src/cli.rs @@ -10,7 +10,9 @@ use clap::{value_parser, Arg, ArgAction, Command}; use serde::Serialize; use tower_lsp::lsp_types::Url; use zerosyntax_analysis::diagnostics; -use zerosyntax_analysis::index::{definitions_in, module_tags_in, references_in}; +use zerosyntax_analysis::index::{ + definitions_in, module_tags_in, object_models_in, object_parents_in, references_in, +}; use zerosyntax_analysis::{Analyzer, Diagnostic, Severity, WorkspaceIndex}; use zerosyntax_syntax::Parse; @@ -268,10 +270,12 @@ fn has_extension(path: &Path, extension: &str) -> bool { } fn apply_entries(index: &mut WorkspaceIndex, entries: Vec) { - for (file, definitions, references, tags, models, _) in entries { + for (file, definitions, references, tags, object_models, object_parents, models, _) 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); } } @@ -280,6 +284,8 @@ fn index_document(index: &mut WorkspaceIndex, analyzer: &Analyzer, parse: &Parse index.set_file(file, definitions_in(analyzer, parse, file)); index.set_file_refs(file, references_in(analyzer, parse)); index.set_file_tags(file, module_tags_in(analyzer, parse)); + index.set_file_object_models(file, object_models_in(analyzer, parse)); + index.set_file_object_parents(file, object_parents_in(parse)); } struct TargetDocument { diff --git a/crates/server/src/scan.rs b/crates/server/src/scan.rs index f2f677b..26a6557 100644 --- a/crates/server/src/scan.rs +++ b/crates/server/src/scan.rs @@ -7,7 +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, references_in, Definition, ModelAsset, ReferenceSite, + definitions_in, module_tags_in, object_models_in, object_parents_in, references_in, Definition, + ModelAsset, ReferenceSite, }; use zerosyntax_analysis::Analyzer; @@ -16,6 +17,8 @@ pub(crate) type ScanEntry = ( Vec, Vec, Vec<(String, String)>, + Vec<(String, Vec)>, + Vec<(String, String)>, Vec, Option>, ); @@ -260,6 +263,8 @@ pub(crate) fn scan_big(analyzer: &Analyzer, path: &Path) -> Result Result Result> { definitions_in(analyzer, &parse, uri.as_str()), references_in(analyzer, &parse), module_tags_in(analyzer, &parse), + object_models_in(analyzer, &parse), + object_parents_in(&parse), Vec::new(), None, )]) @@ -377,6 +393,8 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { Vec::new(), Vec::new(), Vec::new(), + Vec::new(), + Vec::new(), models, None, )) diff --git a/crates/syntax/src/ast.rs b/crates/syntax/src/ast.rs index 9d78a2e..a2bc749 100644 --- a/crates/syntax/src/ast.rs +++ b/crates/syntax/src/ast.rs @@ -61,6 +61,12 @@ impl Block { .nth(1) } + pub fn parent_name(&self) -> Option { + header_tokens(&self.0) + .filter(|t| token_kind(t) == SyntaxKind::WORD) + .nth(2) + } + pub fn fields(&self) -> impl Iterator + '_ { self.0.children().filter_map(Field::cast) } diff --git a/docs/language-server.md b/docs/language-server.md index f7ff767..07a403e 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -85,6 +85,7 @@ symbols. { "format": { "enable": false }, "schemaPath": "C:/Mods/MyMod/schema.json", + "analysis": { "modelMemberStrictness": "compatible" }, "baseIniRoots": [ "C:/Games/Zero Hour", "C:/Mods/MyMod/Data/INI", @@ -97,6 +98,9 @@ symbols. It defaults to `false`. - `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`. - `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/README.md b/editors/vscode/README.md index c2e2532..ad91ef6 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -37,6 +37,7 @@ checks. | --- | --- | --- | | `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for map and model 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.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.trace.server` | `off` | Logs LSP traffic for troubleshooting. | diff --git a/editors/vscode/package.json b/editors/vscode/package.json index bdd8d39..f3637e1 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -75,6 +75,12 @@ "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." }, + "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." + }, "zerosyntax.trace.server": { "type": "string", "enum": [ diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index f14af68..570cb47 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -40,6 +40,9 @@ export function activate(context: vscode.ExtensionContext) { }, baseIniRoots: setting("baseIniRoots", []), schemaPath: setting("schema.path", ""), + analysis: { + modelMemberStrictness: setting("analysis.modelMemberStrictness", "compatible"), + }, clientBaseIniHint: true, }), }; From 2c88553a786cac16591348d80487d12d774a997b Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:46:25 +0200 Subject: [PATCH 4/6] Allow whitespace after value prefixes (#49) --- crates/analysis/src/actions.rs | 11 +- crates/analysis/src/completion.rs | 41 ++++- crates/analysis/src/diagnostics.rs | 176 +++++++++++++------ crates/analysis/src/index.rs | 18 +- crates/analysis/src/model.rs | 15 +- crates/analysis/src/nav.rs | 67 +++---- crates/analysis/src/semantic.rs | 6 +- crates/analysis/tests/spec/ValueGoodTest.ini | 10 +- crates/schema/src/lib.rs | 113 ++++++++++++ 9 files changed, 355 insertions(+), 102 deletions(-) diff --git a/crates/analysis/src/actions.rs b/crates/analysis/src/actions.rs index ff19023..75cb19f 100644 --- a/crates/analysis/src/actions.rs +++ b/crates/analysis/src/actions.rs @@ -152,8 +152,15 @@ fn suggest_in_field(analyzer: &Analyzer, node: &SyntaxNode, range: Span, out: &m check(tok, value_set, true); } } - ValueType::TokenList { tokens: specs } => { - for (spec, tok) in specs.iter().zip(tokens.iter()) { + ValueType::TokenList { .. } | ValueType::OneOf { .. } | ValueType::Prefixed { .. } => { + let input = tokens + .iter() + .map(|token| token.text().trim_matches('"')) + .collect::>(); + for (index, tok) in tokens.iter().enumerate() { + let Some(spec) = schema_field.value_type.token_type_at_input(&input, index) else { + continue; + }; match spec { ValueType::Enum { value_set } => check(tok, value_set, false), ValueType::BitFlags { value_set } => check(tok, value_set, true), diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index 877e78d..45962e4 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -119,17 +119,48 @@ fn classify_position(analyzer: &Analyzer, root: &SyntaxNode, offset: u32) -> Pos .map(|k| k.text().to_string()) .unwrap_or_default(); let value_tokens = field.value_tokens(); - let value_index = value_tokens + let raw_value_index = value_tokens .iter() .filter(|t| u32::from(t.text_range().end()) < offset) .count(); + let input = value_tokens + .iter() + .map(|token| token.text().trim_matches('"')) + .collect::>(); + let value_index = scope_node + .as_ref() + .and_then(|scope_node| scope_schema(analyzer, scope_node).field(&key)) + .and_then(|field| { + field + .value_type + .token_index_at_input(&input, raw_value_index) + }) + .unwrap_or(raw_value_index); let current_token = value_tokens .iter() - .find(|t| { + .position(|t| { let range = t.text_range(); u32::from(range.start()) <= offset && offset <= u32::from(range.end()) }) - .map(|t| t.text().trim_matches('"').to_string()); + .map(|index| { + let current = value_tokens[index].text().trim_matches('"'); + index + .checked_sub(1) + .and_then(|index| value_tokens.get(index)) + .map(|previous| previous.text().trim_matches('"')) + .filter(|previous| previous.ends_with(':')) + .map_or_else( + || current.to_string(), + |previous| format!("{previous}{current}"), + ) + }) + .or_else(|| { + raw_value_index + .checked_sub(1) + .and_then(|index| input.get(index)) + .filter(|previous| previous.ends_with(':')) + .map(|previous| (*previous).to_string()) + }); let first_token = value_tokens .first() .map(|t| t.text().trim_matches('"').to_string()); @@ -1023,8 +1054,8 @@ End #[test] fn loc_variant_reference_suggests_while_typing() { - let src = "Object Tank\n Behavior = TransitionDamageFX ModuleTag_01\n DamagedFXList1 = Loc:X:0.0 Y:0.0 Z:0.0 FXList:FX_\n End\nEnd\n"; - let offset = src.find("FXList:FX_").unwrap() + "FXList:FX_".len(); + let src = "Object Tank\n Behavior = TransitionDamageFX ModuleTag_01\n ReallyDamagedFXList1 = Loc: X:0 Y:0 Z:0 FXList: FX_\n End\nEnd\n"; + let offset = src.find("FXList: FX_").unwrap() + "FXList: FX_".len(); let got = item_with_defs( src, offset as u32, diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index 5a4f1bf..94ce522 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -477,6 +477,10 @@ fn collect_map_reference_diags( fn reference_tokens(field: &Field, ty: &ValueType) -> Vec<(RefKind, SyntaxToken)> { let tokens = field.value_tokens(); match ty { + ValueType::OneOf { .. } => ty + .variant_for_first_token(tokens.first().map(|token| unquote(token.text()))) + .map(|variant| reference_tokens(field, variant)) + .unwrap_or_default(), ValueType::Reference { ref_kind } => tokens .first() .map(|tok| vec![(*ref_kind, tok.clone())]) @@ -485,16 +489,24 @@ fn reference_tokens(field: &Field, ty: &ValueType) -> Vec<(RefKind, SyntaxToken) .into_iter() .map(|tok| (*ref_kind, tok)) .collect::>(), - ValueType::TokenList { tokens: specs } => specs - .iter() - .zip(tokens) - .filter_map(|(spec, tok)| match spec { - ValueType::Reference { ref_kind } | ValueType::ReferenceList { ref_kind } => { - Some((*ref_kind, tok)) - } - _ => None, - }) - .collect(), + ValueType::TokenList { .. } | ValueType::Prefixed { .. } => { + let input = tokens + .iter() + .map(|token| unquote(token.text())) + .collect::>(); + tokens + .iter() + .cloned() + .enumerate() + .filter_map( + |(index, tok)| match ty.token_type_at_input(&input, index)? { + ValueType::Reference { ref_kind } + | ValueType::ReferenceList { ref_kind } => Some((*ref_kind, tok)), + _ => None, + }, + ) + .collect() + } _ => Vec::new(), } } @@ -781,17 +793,24 @@ impl<'a> Ctx<'a> { let tokens = field.value_tokens(); match &schema_field.value_type { ValueType::TokenList { tokens: specs } => { - for (spec, tok) in specs.iter().zip(tokens.iter()) { + let mut i = 0; + for spec in specs { + let Some(tok) = tokens.get(i) else { break }; + let (ty, tok, consumed) = split_prefixed_token(&tokens[i..], spec) + .map(|(ty, tok)| (ty, tok, 2)) + .unwrap_or((spec, tok, 1)); self.validate_model_asset_token( - spec, + ty, tok, scope_node, schema_field.model_source.as_ref(), ); + i += consumed; } } ty => { if let Some(tok) = tokens.first() { + let (ty, tok) = split_prefixed_token(&tokens, ty).unwrap_or((ty, tok)); self.validate_model_asset_token( ty, tok, @@ -811,7 +830,19 @@ impl<'a> Ctx<'a> { source: Option<&zerosyntax_schema::ModelSource>, ) { let Some(index) = self.index else { return }; - let value = unquote(tok.text()); + let raw = unquote(tok.text()); + let (ty, value) = match ty { + ValueType::Prefixed { prefix, value_type } => { + let Some((actual, value)) = raw.split_once(':') else { + return; + }; + if !actual.eq_ignore_ascii_case(prefix) { + return; + } + (value_type.as_ref(), value) + } + _ => (ty, raw), + }; if value.is_empty() || value.eq_ignore_ascii_case("None") { return; } @@ -1012,6 +1043,9 @@ impl<'a> Ctx<'a> { if tokens.is_empty() { return; } + let ty = ty + .variant_for_first_token(tokens.first().map(|token| unquote(token.text()))) + .unwrap_or(ty); match ty { ValueType::BitFlags { .. } | ValueType::ReferenceList { .. } => { for token in &tokens { @@ -1019,18 +1053,17 @@ impl<'a> Ctx<'a> { } } ValueType::TokenList { tokens: specs } => { - for (token, spec) in tokens.iter().zip(specs) { - self.check_token(token, spec); + let mut index = 0; + for spec in specs { + if tokens.get(index).is_none() { + break; + } + index += self.check_value_tokens(&tokens[index..], spec); } } - ValueType::OneOf { .. } => { - if let Some(variant) = - ty.variant_for_first_token(tokens.first().map(|t| unquote(t.text()))) - { - self.check_token(&tokens[0], variant); - } + single => { + self.check_value_tokens(&tokens, single); } - single => self.check_token(&tokens[0], single), } } @@ -1076,9 +1109,10 @@ impl<'a> Ctx<'a> { // A fixed sequence of typed tokens; each listed token is required // (the engine's parse function calls getNextToken for each). ValueType::TokenList { tokens: specs } => { - for (i, spec) in specs.iter().enumerate() { + let mut i = 0; + for spec in specs { match tokens.get(i) { - Some(tok) => self.check_token(tok, spec), + Some(_) => i += self.check_value_tokens(&tokens[i..], spec), None => { if let Some(key) = field.key() { self.warning( @@ -1100,7 +1134,7 @@ impl<'a> Ctx<'a> { .last() .filter(|spec| matches!(spec, ValueType::BitFlags { .. })) { - for tok in tokens.iter().skip(specs.len()) { + for tok in tokens.iter().skip(i) { self.check_token(tok, spec); } } @@ -1111,10 +1145,31 @@ impl<'a> Ctx<'a> { // `X:0 Y:0 [Z:0]` — reals (INI.cpp parseCoord2D / parseCoord3D). ValueType::Coord2D => self.check_axes(field, &tokens, &["X", "Y"], None, false), ValueType::Coord3D => self.check_axes(field, &tokens, &["X", "Y", "Z"], None, false), - single => self.check_token(&tokens[0], single), + single => { + self.check_value_tokens(&tokens, single); + } } } + /// Validate one logical value, accepting the engine's optional whitespace + /// after a prefix colon (`Loc:X:0` and `Loc: X:0`). + fn check_value_tokens(&mut self, tokens: &[SyntaxToken], ty: &ValueType) -> usize { + let tok = &tokens[0]; + if let Some((value_type, value)) = split_prefixed_token(tokens, ty) { + if ty + .first_prefix() + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("Loc")) + { + self.check_loc_value(value, unquote(value.text())); + } else { + self.check_token(value, value_type); + } + return 2; + } + self.check_token(tok, ty); + 1 + } + /// Validate one value token against a single-token type. fn check_token(&mut self, tok: &SyntaxToken, ty: &ValueType) { match ty { @@ -1209,29 +1264,7 @@ impl<'a> Ctx<'a> { return; } if prefix.eq_ignore_ascii_case("Loc") { - let Some((axis, n)) = value.split_once(':') else { - self.error( - tok, - "bad-prefixed", - format!("expected `Loc:X:`, found `{text}`"), - ); - return; - }; - if !axis.eq_ignore_ascii_case("X") { - self.error( - tok, - "bad-prefixed", - format!("expected `Loc:X:`, found `{text}`"), - ); - return; - } - if n.parse::().is_err() { - self.error( - tok, - "bad-number", - format!("expected a number for `Loc:X:`, found `{n}`"), - ); - } + self.check_loc_value(tok, value); return; } match ty { @@ -1268,6 +1301,32 @@ impl<'a> Ctx<'a> { } } + fn check_loc_value(&mut self, tok: &SyntaxToken, value: &str) { + let Some((axis, n)) = value.split_once(':') else { + self.error( + tok, + "bad-prefixed", + format!("expected `X:` after `Loc:`, found `{value}`"), + ); + return; + }; + if !axis.eq_ignore_ascii_case("X") { + self.error( + tok, + "bad-prefixed", + format!("expected `X:` after `Loc:`, found `{value}`"), + ); + return; + } + if n.parse::().is_err() { + self.error( + tok, + "bad-number", + format!("expected a number for `Loc:X:`, found `{n}`"), + ); + } + } + fn check_prefixed_number( &mut self, tok: &SyntaxToken, @@ -1526,6 +1585,15 @@ fn unquote(s: &str) -> &str { .unwrap_or(s) } +fn split_prefixed_token<'t, 'v>( + tokens: &'t [SyntaxToken], + ty: &'v ValueType, +) -> Option<(&'v ValueType, &'t SyntaxToken)> { + let value = tokens.get(1)?; + ty.split_prefix_value_type(unquote(tokens.first()?.text())) + .map(|value_type| (value_type, value)) +} + #[cfg(test)] mod tests { use super::*; @@ -1975,6 +2043,9 @@ Object Tank WeaponRecoilBone = PRIMARY MissingMuzzle End End + Behavior = BoneFXUpdate ModuleTag_02 + PristineFXList1 = Bone: SplitMissing OnlyOnce: No 0 0 FXList: None + End End "; let parse = a.parse(src); @@ -2006,6 +2077,13 @@ End }), "{diags:?}" ); + assert!( + diags.iter().any(|d| { + d.code == "unknown-model-member" + && &src[d.span.start as usize..d.span.end as usize] == "SplitMissing" + }), + "{diags:?}" + ); assert!(!diags.iter().any(|d| { d.code == "unknown-model-member" && &src[d.span.start as usize..d.span.end as usize] == "Muzzle" diff --git a/crates/analysis/src/index.rs b/crates/analysis/src/index.rs index 780b6e7..b516ec5 100644 --- a/crates/analysis/src/index.rs +++ b/crates/analysis/src/index.rs @@ -683,8 +683,15 @@ fn collect_refs_from_type( ); } } - ValueType::TokenList { tokens: specs } => { - for (spec, tok) in specs.iter().zip(tokens.iter()) { + ValueType::TokenList { .. } | ValueType::Prefixed { .. } => { + let input = tokens + .iter() + .map(|token| token.text().trim_matches('"')) + .collect::>(); + for (index, tok) in tokens.iter().enumerate() { + let Some(spec) = ty.token_type_at_input(&input, index) else { + continue; + }; match spec { ValueType::Reference { ref_kind } | ValueType::ReferenceList { ref_kind } => { push( @@ -704,6 +711,9 @@ fn collect_refs_from_type( if !actual.eq_ignore_ascii_case(prefix) { continue; } + if name.is_empty() { + continue; + } let start = u32::from(tok.text_range().start()) + u32::from(tok.text().starts_with('"')) + actual.len() as u32 @@ -865,9 +875,9 @@ mod tests { } #[test] - fn quoted_prefixed_reference_site_span_excludes_quotes_and_prefix() { + fn split_prefixed_reference_site_span_excludes_prefix() { let a = Analyzer::embedded(); - let src = "Object Tank\n Behavior = TransitionDamageFX ModuleTag_01\n DamagedParticleSystem1 = Bone:NONE RandomBone:No \"PSys:MissingParticle\"\n End\nEnd\n"; + let src = "Object Tank\n Behavior = TransitionDamageFX ModuleTag_01\n DamagedParticleSystem1 = Bone: NONE RandomBone: No PSys: MissingParticle\n End\nEnd\n"; let refs = references_in(&a, &a.parse(src)); let reference = refs .iter() diff --git a/crates/analysis/src/model.rs b/crates/analysis/src/model.rs index 99140ff..228625f 100644 --- a/crates/analysis/src/model.rs +++ b/crates/analysis/src/model.rs @@ -248,9 +248,20 @@ fn collect_models(analyzer: &Analyzer, node: &SyntaxNode, out: &mut Vec) continue; }; let values = field.value_tokens(); + let input = values + .iter() + .map(|value| value.text().trim_matches('"')) + .collect::>(); match &schema_field.value_type { - ValueType::TokenList { tokens } => { - for (spec, value) in tokens.iter().zip(values.iter()) { + ValueType::TokenList { .. } + | ValueType::OneOf { .. } + | ValueType::Prefixed { .. } => { + for (index, value) in values.iter().enumerate() { + let Some(spec) = + schema_field.value_type.token_type_at_input(&input, index) + else { + continue; + }; if is_model_asset_type(spec) { out.push(value.text().trim_matches('"').to_string()); } diff --git a/crates/analysis/src/nav.rs b/crates/analysis/src/nav.rs index f71d177..cfe95c2 100644 --- a/crates/analysis/src/nav.rs +++ b/crates/analysis/src/nav.rs @@ -57,51 +57,42 @@ pub fn reference_at(analyzer: &Analyzer, parse: &Parse, offset: u32) -> Option *ref_kind, - ValueType::ReferenceList { ref_kind } => *ref_kind, - ValueType::TokenList { tokens } => match tokens.get(pos)? { + let input = value_tokens + .iter() + .map(|token| token.text().trim_matches('"')) + .collect::>(); + let element_type = active_type.token_type_at_input(&input, pos)?; + let ref_kind = match element_type { + ValueType::Reference { ref_kind } | ValueType::ReferenceList { ref_kind } => *ref_kind, + ValueType::Prefixed { value_type, .. } => match value_type.as_ref() { ValueType::Reference { ref_kind } | ValueType::ReferenceList { ref_kind } => *ref_kind, - ValueType::Prefixed { value_type, .. } => match value_type.as_ref() { - ValueType::Reference { ref_kind } | ValueType::ReferenceList { ref_kind } => { - *ref_kind - } - _ => return None, - }, _ => return None, }, _ => return None, }; - let (name, span) = if let ValueType::TokenList { tokens } = active_type { - match tokens.get(pos) { - Some(ValueType::Prefixed { prefix, .. }) => { - let text = tok.text().trim_matches('"'); - let (actual, name) = text.split_once(':')?; - if !actual.eq_ignore_ascii_case(prefix) { - return None; - } - let start = u32::from(tok.text_range().start()) - + u32::from(tok.text().starts_with('"')) - + actual.len() as u32 - + 1; - ( - name.to_string(), - crate::Span { - start, - end: start + name.len() as u32, - }, - ) + let (name, span) = match element_type { + ValueType::Prefixed { prefix, .. } => { + let text = tok.text().trim_matches('"'); + let (actual, name) = text.split_once(':')?; + if !actual.eq_ignore_ascii_case(prefix) || name.is_empty() { + return None; } - _ => ( - tok.text().trim_matches('"').to_string(), - tok.text_range().into(), - ), + let start = u32::from(tok.text_range().start()) + + u32::from(tok.text().starts_with('"')) + + actual.len() as u32 + + 1; + ( + name.to_string(), + crate::Span { + start, + end: start + name.len() as u32, + }, + ) } - } else { - ( + _ => ( tok.text().trim_matches('"').to_string(), tok.text_range().into(), - ) + ), }; Some(ReferenceAt { kind: ref_kind, @@ -189,9 +180,9 @@ mod tests { } #[test] - fn quoted_prefixed_reference_span_excludes_quotes_and_prefix() { + fn split_prefixed_reference_span_excludes_prefix() { let a = Analyzer::embedded(); - let src = "Object Tank\n Behavior = TransitionDamageFX ModuleTag_01\n DamagedParticleSystem1 = Bone:NONE RandomBone:No \"PSys:MissingParticle\"\n End\nEnd\n"; + let src = "Object Tank\n Behavior = TransitionDamageFX ModuleTag_01\n DamagedParticleSystem1 = Bone: NONE RandomBone: No PSys: MissingParticle\n End\nEnd\n"; let offset = src.find("MissingParticle").unwrap() as u32; let reference = reference_at(&a, &a.parse(src), offset).unwrap(); assert_eq!(reference.kind, RefKind::ParticleSystem); diff --git a/crates/analysis/src/semantic.rs b/crates/analysis/src/semantic.rs index 26e1c48..b352f72 100644 --- a/crates/analysis/src/semantic.rs +++ b/crates/analysis/src/semantic.rs @@ -182,9 +182,13 @@ impl<'a> Sem<'a> { let active_ty = ty.as_ref().and_then(|ty| { ty.variant_for_first_token(value_tokens.first().map(|t| t.text().trim_matches('"'))) }); + let input = value_tokens + .iter() + .map(|token| token.text().trim_matches('"')) + .collect::>(); for (i, tok) in value_tokens.iter().enumerate() { // Token lists classify each position by its own element type. - let elem = active_ty.and_then(|ty| ty.token_type_at(i)); + let elem = active_ty.and_then(|ty| ty.token_type_at_input(&input, i)); self.set(tok, value_token_kind(tok, elem)); } } diff --git a/crates/analysis/tests/spec/ValueGoodTest.ini b/crates/analysis/tests/spec/ValueGoodTest.ini index 72ffe03..35b1c42 100644 --- a/crates/analysis/tests/spec/ValueGoodTest.ini +++ b/crates/analysis/tests/spec/ValueGoodTest.ini @@ -3,7 +3,7 @@ ; Colors: plain, with alpha, spaced colon, lowercase tags. MultiplayerColor ValueGood - TooltipName = Color:ValueGood + TooltipName = Color: ValueGood RGBColor = R:255 G:0 B:64 RGBNightColor = r:12 g: 200 B:0 A:128 End @@ -24,6 +24,11 @@ End ; WeaponSet / ArmorSet with engine-faithful fields; Tracer nugget color. Object ValueGoodObject + Behavior = TransitionDamageFX ModuleTag_TransitionDamage + DamagedFXList1 = Loc: X:0.0 Y:0.0 Z:0.0 FXList: FX_ValueGood + ReallyDamagedFXList1 = Loc: X:0 Y:0 Z:0 FXList:FX_ValueGood + DamagedParticleSystem1 = Bone: NONE RandomBone: No PSys: PSys_ValueGood + End WeaponSet Conditions = None Weapon = PRIMARY ValueGoodRealWeapon @@ -39,6 +44,9 @@ Object ValueGoodObject End End +ParticleSystem PSys_ValueGood +End + Weapon ValueGoodRealWeapon PrimaryDamage = 10.0 End diff --git a/crates/schema/src/lib.rs b/crates/schema/src/lib.rs index e6e2384..1b103c1 100644 --- a/crates/schema/src/lib.rs +++ b/crates/schema/src/lib.rs @@ -243,6 +243,17 @@ pub enum ValueType { } impl ValueType { + pub fn split_prefix_value_type(&self, token: &str) -> Option<&ValueType> { + let ValueType::Prefixed { prefix, value_type } = self else { + return None; + }; + token + .trim_matches('"') + .strip_suffix(':') + .is_some_and(|actual| actual.eq_ignore_ascii_case(prefix)) + .then_some(value_type.as_ref()) + } + pub fn first_prefix(&self) -> Option<&str> { match self { ValueType::Prefixed { prefix, .. } => Some(prefix), @@ -279,6 +290,84 @@ impl ValueType { .filter(|ty| matches!(ty, ValueType::BitFlags { .. })) }) } + + /// The value type at a raw syntax-token position, accounting for the + /// engine-valid split form `Prefix: Value`. + pub fn token_type_at_input(&self, input: &[&str], index: usize) -> Option<&ValueType> { + let active = self.variant_for_first_token(input.first().copied())?; + if !std::ptr::eq(active, self) { + return active.token_type_at_input(input, index); + } + let ValueType::TokenList { tokens } = active else { + if let Some(value_type) = + active.split_prefix_value_type(input.first().copied().unwrap_or_default()) + { + return match index { + 0 => Some(active), + 1 => Some(value_type), + _ => None, + }; + } + return match active { + ValueType::BitFlags { .. } | ValueType::ReferenceList { .. } => Some(active), + _ if index == 0 => Some(active), + _ => None, + }; + }; + let mut raw = 0; + for ty in tokens { + if let Some(value_type) = + ty.split_prefix_value_type(input.get(raw).copied().unwrap_or_default()) + { + if index == raw { + return Some(ty); + } + if index == raw + 1 { + return Some(value_type); + } + raw += 2; + } else { + if index == raw { + return Some(ty); + } + raw += 1; + } + } + tokens + .last() + .filter(|ty| index >= raw && matches!(ty, ValueType::BitFlags { .. })) + } + + /// The schema token position corresponding to a raw syntax-token position. + /// A split `Prefix: Value` pair occupies one schema position. + pub fn token_index_at_input(&self, input: &[&str], index: usize) -> Option { + let active = self.variant_for_first_token(input.first().copied())?; + if !std::ptr::eq(active, self) { + return active.token_index_at_input(input, index); + } + let ValueType::TokenList { tokens } = active else { + return Some(0); + }; + let mut raw = 0; + for (logical, ty) in tokens.iter().enumerate() { + let width = if ty + .split_prefix_value_type(input.get(raw).copied().unwrap_or_default()) + .is_some() + { + 2 + } else { + 1 + }; + if index < raw + width { + return Some(logical); + } + raw += width; + } + tokens + .last() + .filter(|ty| index >= raw && matches!(ty, ValueType::BitFlags { .. })) + .map(|_| tokens.len() - 1) + } } /// The kind of named definition a reference points at (or a block defines). @@ -438,6 +527,30 @@ mod tests { ); } + #[test] + fn split_prefixes_keep_raw_tokens_on_their_schema_positions() { + let ty = token_list(vec![ + prefixed("Loc", ValueType::AsciiString), + prefixed("Y", ValueType::Real), + prefixed("Z", ValueType::Real), + prefixed("FXList", reference(RefKind::FxList)), + ]); + let input = ["Loc:", "X:0", "Y:", "0", "Z:", "0", "FXList:", "Effect"]; + + assert_eq!( + (0..input.len()) + .map(|index| ty.token_index_at_input(&input, index).unwrap()) + .collect::>(), + [0, 0, 1, 1, 2, 2, 3, 3] + ); + assert!(matches!( + ty.token_type_at_input(&input, 7), + Some(ValueType::Reference { + ref_kind: RefKind::FxList + }) + )); + } + fn contains(ty: &ValueType, predicate: fn(&ValueType) -> bool) -> bool { predicate(ty) || match ty { From 3ea9a7c50a59794d84a780cb0d5b8e4c5252b486 Mon Sep 17 00:00:00 2001 From: Mads Jans <90150876+ViTeXFTW@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:55:15 +0200 Subject: [PATCH 5/6] Add accurate map.ini ordering diagnostics (#50) --- crates/analysis/src/diagnostics.rs | 537 +++++++++++++++++++++-- crates/analysis/tests/spec/Map.ini | 61 +++ crates/analysis/tests/spec/Map.spec.toml | 54 +++ crates/server/src/backend.rs | 67 ++- crates/syntax/src/ast.rs | 4 +- docs/diagnostics.md | 2 +- docs/language-server.md | 7 +- editors/vscode/README.md | 1 + editors/vscode/package.json | 5 + editors/vscode/src/extension.ts | 1 + 10 files changed, 685 insertions(+), 54 deletions(-) diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index 94ce522..3cdf4b1 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -16,7 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use zerosyntax_schema::{RefKind, ValueType}; +use zerosyntax_schema::{Field as SchemaField, RefKind, ValueType}; use zerosyntax_syntax::ast::{Block, Field, Module}; use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode, SyntaxToken}; @@ -365,9 +365,10 @@ fn short_file(file: &str) -> &str { file.rsplit(['/', '\\']).next().unwrap_or(file) } -#[derive(Clone, Copy)] +#[derive(Clone)] struct MapDef { order: usize, + header: String, } type MapDefs = HashMap<(RefKind, String), Vec>; @@ -378,9 +379,9 @@ fn map_layer_diagnostics( index: Option<&WorkspaceIndex>, file: Option<&str>, ) -> Vec { - if !file.is_some_and(is_override_layer) { + let Some(file) = file.filter(|file| is_override_layer(file)) else { return Vec::new(); - } + }; let defs = map_top_level_defs(analyzer, parse); let mut out = Vec::new(); @@ -390,7 +391,26 @@ fn map_layer_diagnostics( .filter(|n| n.kind() == SyntaxKind::BLOCK) .enumerate() { - collect_map_reference_diags(analyzer, &node, order, &defs, index, &mut out); + let consumer = block_header(&Block(node.clone())); + collect_map_reference_diags( + analyzer, + &node, + order, + &defs, + index, + &consumer, + short_file(file), + &mut out, + ); + collect_map_reskin_diag( + &node, + order, + &defs, + index, + &consumer, + short_file(file), + &mut out, + ); collect_map_projectile_diags(&node, &defs, &mut out); } out @@ -405,27 +425,60 @@ fn map_top_level_defs(analyzer: &Analyzer, parse: &Parse) -> MapDefs { .enumerate() { let block = Block(node); - let Some(kind) = block - .keyword() - .and_then(|k| analyzer.block(k.text())) - .and_then(|b| b.defines) - else { + let Some(keyword) = block.keyword() else { + continue; + }; + let Some(kind) = analyzer.block(keyword.text()).and_then(|b| b.defines) else { continue; }; let Some(name) = block.name() else { continue }; out.entry((kind, name.text().to_ascii_lowercase())) .or_default() - .push(MapDef { order }); + .push(MapDef { + order, + header: block_header(&block), + }); } out } +fn block_header(block: &Block) -> String { + [block.keyword(), block.name(), block.parent_name()] + .into_iter() + .flatten() + .map(|token| token.text().to_string()) + .collect::>() + .join(" ") +} + +fn has_base_definition(index: Option<&WorkspaceIndex>, kind: RefKind, name: &str) -> bool { + index.is_some_and(|idx| { + idx.locations(kind, name) + .iter() + .any(|loc| !is_override_layer(&loc.file)) + }) +} + +fn later_map_definition<'a>( + defs: &'a MapDefs, + kind: RefKind, + name: &str, + order: usize, +) -> Option<&'a MapDef> { + defs.get(&(kind, name.to_ascii_lowercase()))? + .iter() + .find(|site| site.order > order) +} + +#[allow(clippy::too_many_arguments)] fn collect_map_reference_diags( analyzer: &Analyzer, node: &SyntaxNode, order: usize, defs: &MapDefs, index: Option<&WorkspaceIndex>, + consumer: &str, + file: &str, out: &mut Vec, ) { let scope = scope_schema(analyzer, node); @@ -437,44 +490,110 @@ fn collect_map_reference_diags( let Some(schema_field) = scope.field(key.text()) else { continue; }; - for (kind, tok) in reference_tokens(&field, &schema_field.value_type) { - let name = unquote(tok.text()); + for reference in reference_tokens(&field, &schema_field.value_type) { + if !eager_map_reference(&scope, schema_field, reference.kind) { + continue; + } + let name = reference.name; if name.eq_ignore_ascii_case("None") { continue; } - if index.is_some_and(|idx| { - idx.locations(kind, name) - .iter() - .any(|loc| !is_override_layer(&loc.file)) - }) { + if has_base_definition(index, reference.kind, &name) { continue; } - let later = defs - .get(&(kind, name.to_ascii_lowercase())) - .is_some_and(|sites| sites.iter().any(|site| site.order > order)); - if later { + if let Some(site) = later_map_definition(defs, reference.kind, &name, order) { + let field_kind = if key.text().chars().all(|c| c.is_ascii_digit()) { + "slot" + } else { + "field" + }; out.push(Diagnostic { - span: tok.text_range().into(), + span: reference.token.text_range().into(), severity: Severity::Warning, code: "map-forward-reference", message: format!( - "`{name}` is defined later in this map file; map.ini/solo.ini \ - loads in order, so this reference binds before that definition \ - and the later map entry will not take effect here" + "`{}` is declared after `{consumer}`, but {field_kind} `{}` is \ + resolved immediately while `{file}` loads. Move `{}` above \ + `{consumer}`.", + site.header, + key.text(), + site.header, ), }); } } } SyntaxKind::MODULE | SyntaxKind::BLOCK => { - collect_map_reference_diags(analyzer, &child, order, defs, index, out); + collect_map_reference_diags( + analyzer, &child, order, defs, index, consumer, file, out, + ); } _ => {} } } } -fn reference_tokens(field: &Field, ty: &ValueType) -> Vec<(RefKind, SyntaxToken)> { +fn eager_map_reference(scope: &ScopeSchema<'_>, field: &SchemaField, kind: RefKind) -> bool { + if matches!(scope, ScopeSchema::Block(block) if block.name == "Object") + && matches!(field.name.as_str(), "SelectPortrait" | "ButtonImage") + { + return true; + } + + if field.parse_fn == "parseFactionObjectCreationList" { + return kind == RefKind::ObjectCreationList; + } + + matches!( + field.parse_fn.as_str(), + "AI::parseScience" + | "AIUpdateModuleData::parseLocomotorSet" + | "ArmorStore::parseArmorTemplate" + | "BoneFXUpdateModuleData::parseFXList" + | "BoneFXUpdateModuleData::parseObjectCreationList" + | "BoneFXUpdateModuleData::parseParticleSystem" + | "CommandSet::parseCommandButton" + | "DamageFX::parseMajorFXList" + | "DamageFX::parseMinorFXList" + | "DamageFXStore::parseDamageFX" + | "INI::parseFXList" + | "INI::parseMappedImage" + | "INI::parseObjectCreationList" + | "INI::parseParticleSystemTemplate" + | "INI::parseScience" + | "INI::parseScienceVector" + | "INI::parseSpecialPowerTemplate" + | "INI::parseThingTemplate" + | "INI::parseUpgradeTemplate" + | "INI::parseWeaponTemplate" + | "ProductionPrerequisite::parsePrerequisiteScience" + | "ProductionPrerequisite::parsePrerequisiteUnit" + | "TransitionDamageFXModuleData::parseFXList" + | "TransitionDamageFXModuleData::parseObjectCreationList" + | "TransitionDamageFXModuleData::parseParticleSystem" + | "WeaponTemplateSet::parseWeapon" + | "parseAllVetLevelsFXList" + | "parseAllVetLevelsPSys" + | "parseAngleFX" + | "parseBountyUpgradePair" + | "parseCashHackUpgradePair" + | "parseFX" + | "parseOCL" + | "parseOCLUpgradePair" + | "parseParticleSysBone" + | "parsePerVetLevelFXList" + | "parsePerVetLevelPSys" + | "parseWeapon" + ) +} + +struct MapReference { + kind: RefKind, + token: SyntaxToken, + name: String, +} + +fn reference_tokens(field: &Field, ty: &ValueType) -> Vec { let tokens = field.value_tokens(); match ty { ValueType::OneOf { .. } => ty @@ -483,34 +602,135 @@ fn reference_tokens(field: &Field, ty: &ValueType) -> Vec<(RefKind, SyntaxToken) .unwrap_or_default(), ValueType::Reference { ref_kind } => tokens .first() - .map(|tok| vec![(*ref_kind, tok.clone())]) + .map(|tok| vec![map_reference(*ref_kind, tok, unquote(tok.text()))]) .unwrap_or_default(), ValueType::ReferenceList { ref_kind } => tokens - .into_iter() - .map(|tok| (*ref_kind, tok)) + .iter() + .map(|tok| map_reference(*ref_kind, tok, unquote(tok.text()))) .collect::>(), - ValueType::TokenList { .. } | ValueType::Prefixed { .. } => { - let input = tokens - .iter() - .map(|token| unquote(token.text())) - .collect::>(); - tokens - .iter() - .cloned() - .enumerate() - .filter_map( - |(index, tok)| match ty.token_type_at_input(&input, index)? { - ValueType::Reference { ref_kind } - | ValueType::ReferenceList { ref_kind } => Some((*ref_kind, tok)), - _ => None, - }, - ) - .collect() + ValueType::Prefixed { .. } => { + let mut out = Vec::new(); + reference_value_tokens(ty, &tokens, 0, &mut out); + out + } + ValueType::TokenList { tokens: specs } => { + let mut out = Vec::new(); + let mut raw = 0; + for (i, spec) in specs.iter().enumerate() { + if tokens.get(raw).is_none() { + break; + } + if i + 1 == specs.len() { + if let ValueType::ReferenceList { ref_kind } = spec { + out.extend( + tokens[raw..] + .iter() + .map(|tok| map_reference(*ref_kind, tok, unquote(tok.text()))), + ); + break; + } + } + raw += reference_value_tokens(spec, &tokens, raw, &mut out); + } + out } _ => Vec::new(), } } +fn reference_value_tokens( + ty: &ValueType, + tokens: &[SyntaxToken], + index: usize, + out: &mut Vec, +) -> usize { + let Some(tok) = tokens.get(index) else { + return 0; + }; + if let Some(value_type) = ty.split_prefix_value_type(unquote(tok.text())) { + if let Some(value) = tokens.get(index + 1) { + reference_token(value_type, value, out); + return 2; + } + } + reference_token(ty, tok, out); + 1 +} + +fn map_reference(kind: RefKind, token: &SyntaxToken, name: &str) -> MapReference { + MapReference { + kind, + token: token.clone(), + name: name.to_string(), + } +} + +fn reference_token(ty: &ValueType, tok: &SyntaxToken, out: &mut Vec) { + match ty { + ValueType::Reference { ref_kind } | ValueType::ReferenceList { ref_kind } => { + out.push(map_reference(*ref_kind, tok, unquote(tok.text()))); + } + ValueType::Prefixed { prefix, value_type } => { + let raw = unquote(tok.text()); + let Some((actual, name)) = raw.split_once(':') else { + return; + }; + if !actual.eq_ignore_ascii_case(prefix) { + return; + } + if let ValueType::Reference { ref_kind } | ValueType::ReferenceList { ref_kind } = + value_type.as_ref() + { + out.push(map_reference(*ref_kind, tok, name)); + } + } + ValueType::OneOf { .. } => { + if let Some(variant) = ty.variant_for_first_token(Some(unquote(tok.text()))) { + reference_token(variant, tok, out); + } + } + _ => {} + } +} + +fn collect_map_reskin_diag( + node: &SyntaxNode, + order: usize, + defs: &MapDefs, + index: Option<&WorkspaceIndex>, + consumer: &str, + file: &str, + out: &mut Vec, +) { + let block = Block(node.clone()); + if !block + .keyword() + .is_some_and(|keyword| keyword.text().eq_ignore_ascii_case("ObjectReskin")) + { + return; + } + let Some(parent) = block.parent_name() else { + return; + }; + let name = unquote(parent.text()); + if has_base_definition(index, RefKind::Object, name) { + return; + } + let Some(site) = later_map_definition(defs, RefKind::Object, name, order) else { + return; + }; + out.push(Diagnostic { + span: parent.text_range().into(), + severity: Severity::Warning, + code: "map-forward-reference", + message: format!( + "`{}` is declared after `{consumer}`; `ObjectReskin` requires its parent to exist \ + when parsed while `{file}` loads. Move `{}` above `{consumer}`.", + site.header, site.header, + ), + }); +} + fn collect_map_projectile_diags(node: &SyntaxNode, defs: &MapDefs, out: &mut Vec) { let block = Block(node.clone()); if !block @@ -1984,6 +2204,227 @@ End ); } + #[test] + fn map_ordering_classifies_only_source_proven_eager_references() { + let eager = [ + "AI::parseScience", + "AIUpdateModuleData::parseLocomotorSet", + "ArmorStore::parseArmorTemplate", + "BoneFXUpdateModuleData::parseFXList", + "BoneFXUpdateModuleData::parseObjectCreationList", + "BoneFXUpdateModuleData::parseParticleSystem", + "CommandSet::parseCommandButton", + "DamageFX::parseMajorFXList", + "DamageFX::parseMinorFXList", + "DamageFXStore::parseDamageFX", + "INI::parseFXList", + "INI::parseMappedImage", + "INI::parseObjectCreationList", + "INI::parseParticleSystemTemplate", + "INI::parseScience", + "INI::parseScienceVector", + "INI::parseSpecialPowerTemplate", + "INI::parseThingTemplate", + "INI::parseUpgradeTemplate", + "INI::parseWeaponTemplate", + "ProductionPrerequisite::parsePrerequisiteScience", + "ProductionPrerequisite::parsePrerequisiteUnit", + "TransitionDamageFXModuleData::parseFXList", + "TransitionDamageFXModuleData::parseObjectCreationList", + "TransitionDamageFXModuleData::parseParticleSystem", + "WeaponTemplateSet::parseWeapon", + "parseAllVetLevelsFXList", + "parseAllVetLevelsPSys", + "parseAngleFX", + "parseBountyUpgradePair", + "parseCashHackUpgradePair", + "parseFX", + "parseOCL", + "parseOCLUpgradePair", + "parseParticleSysBone", + "parsePerVetLevelFXList", + "parsePerVetLevelPSys", + "parseWeapon", + ]; + let mut field = SchemaField { + name: "Test".into(), + value_type: ValueType::Reference { + ref_kind: RefKind::Object, + }, + parse_fn: String::new(), + doc: None, + model_source: None, + }; + for parser in eager { + field.parse_fn = parser.into(); + assert!( + eager_map_reference(&ScopeSchema::Unknown, &field, RefKind::Object), + "{parser}" + ); + } + field.parse_fn = "INI::parseAsciiString".into(); + assert!(!eager_map_reference( + &ScopeSchema::Unknown, + &field, + RefKind::Object + )); + + let a = Analyzer::embedded(); + let ocl_update = a.module("OCLUpdate").unwrap(); + let faction_ocl = ocl_update + .fields + .iter() + .find(|field| field.name == "FactionOCL") + .unwrap(); + assert!(eager_map_reference( + &ScopeSchema::Module(ocl_update), + faction_ocl, + RefKind::ObjectCreationList + )); + assert!(!eager_map_reference( + &ScopeSchema::Module(ocl_update), + faction_ocl, + RefKind::PlayerTemplate + )); + + let object = a.block("Object").unwrap(); + let object_image = object + .fields + .iter() + .find(|field| field.name == "ButtonImage") + .unwrap(); + assert!(eager_map_reference( + &ScopeSchema::Block(object), + object_image, + RefKind::MappedImage + )); + for block_name in ["CommandButton", "Upgrade"] { + let block = a.block(block_name).unwrap(); + let image = block + .fields + .iter() + .find(|field| field.name == "ButtonImage") + .unwrap(); + assert!(!eager_map_reference( + &ScopeSchema::Block(block), + image, + RefKind::MappedImage + )); + } + } + + #[test] + fn map_reference_extraction_handles_prefixed_and_trailing_lists() { + let a = Analyzer::embedded(); + let parse = + a.parse("Object X\n Test = Faction:LateFaction OCL:LateOCL ExtraA ExtraB\nEnd\n"); + let field = Block(parse.syntax().children().next().unwrap()) + .fields() + .next() + .unwrap(); + let prefixed = ValueType::TokenList { + tokens: vec![ + ValueType::Prefixed { + prefix: "Faction".into(), + value_type: Box::new(ValueType::Reference { + ref_kind: RefKind::PlayerTemplate, + }), + }, + ValueType::Prefixed { + prefix: "OCL".into(), + value_type: Box::new(ValueType::Reference { + ref_kind: RefKind::ObjectCreationList, + }), + }, + ], + }; + let refs = reference_tokens(&field, &prefixed); + assert_eq!( + refs.iter().map(|r| r.name.as_str()).collect::>(), + ["LateFaction", "LateOCL"] + ); + + let split_parse = a.parse("Object X\n Test = Faction: LateFaction OCL: LateOCL\nEnd\n"); + let split_field = Block(split_parse.syntax().children().next().unwrap()) + .fields() + .next() + .unwrap(); + let refs = reference_tokens(&split_field, &prefixed); + assert_eq!( + refs.iter().map(|r| r.name.as_str()).collect::>(), + ["LateFaction", "LateOCL"] + ); + + let trailing = ValueType::TokenList { + tokens: vec![ + ValueType::AsciiString, + ValueType::ReferenceList { + ref_kind: RefKind::FxList, + }, + ], + }; + let refs = reference_tokens(&field, &trailing); + assert_eq!( + refs.iter().map(|r| r.name.as_str()).collect::>(), + ["OCL:LateOCL", "ExtraA", "ExtraB"] + ); + } + + #[test] + fn map_forward_reference_message_is_actionable_and_suppressible() { + let a = Analyzer::embedded(); + let body = "CommandSet MapSet\n 1 = LateButton\nEnd\n\nCommandButton LateButton\n Command = UNIT_BUILD\nEnd\n"; + let parse = a.parse(body); + let diag = diagnose(&a, &parse, None, Some("maps/map.ini")) + .into_iter() + .find(|diag| diag.code == "map-forward-reference") + .unwrap(); + for expected in [ + "CommandButton LateButton", + "CommandSet MapSet", + "slot `1`", + "`map.ini`", + "Move `CommandButton LateButton` above `CommandSet MapSet`", + ] { + assert!(diag.message.contains(expected), "{}", diag.message); + } + + let suppressed = a.parse(&format!( + "; zerosyntax-disable: map-forward-reference\n{body}" + )); + assert!(!diagnose(&a, &suppressed, None, Some("maps/map.ini")) + .iter() + .any(|diag| diag.code == "map-forward-reference")); + + let reskin = a.parse("ObjectReskin Child Parent\nEnd\n\nObject Parent\nEnd\n"); + let message = diagnose(&a, &reskin, None, Some("maps/map.ini")) + .into_iter() + .find(|diag| diag.code == "map-forward-reference") + .unwrap() + .message; + assert!(message.contains("`ObjectReskin` requires its parent")); + assert!(message.contains("Move `Object Parent` above `ObjectReskin Child Parent`")); + } + + #[test] + fn map_reskin_parent_allows_base_game_definition() { + let a = Analyzer::embedded(); + let mut index = WorkspaceIndex::new(); + let base = a.parse("Object Parent\nEnd\n"); + index.set_file( + "Data/INI/Object.ini", + crate::index::definitions_in(&a, &base, "Data/INI/Object.ini"), + ); + let map = a.parse("ObjectReskin Child Parent\nEnd\n\nObject Parent\nEnd\n"); + index.set_file( + "maps/map.ini", + crate::index::definitions_in(&a, &map, "maps/map.ini"), + ); + assert!(!diagnose(&a, &map, Some(&index), Some("maps/map.ini")) + .iter() + .any(|diag| diag.code == "map-forward-reference")); + } + #[test] fn duplicate_object_definition_outside_overrides_warns() { let a = Analyzer::embedded(); diff --git a/crates/analysis/tests/spec/Map.ini b/crates/analysis/tests/spec/Map.ini index 6ffaafd..e1a369a 100644 --- a/crates/analysis/tests/spec/Map.ini +++ b/crates/analysis/tests/spec/Map.ini @@ -65,3 +65,64 @@ End Weapon MapProjectileWeapon ProjectileObject = MapProjectile End + +Object PrereqConsumer + Prerequisites + Object = LatePrereq + End +End + +Object LatePrereq +End + +Object PortraitConsumer + SelectPortrait = LatePortrait +End + +MappedImage LatePortrait +End + +ObjectReskin ReskinChild ReskinParent +End + +Object ReskinParent +End + +PlayerTemplate LateBoundPlayer + PurchaseScienceCommandSetRank1 = LatePlayerSet +End + +CommandSet LatePlayerSet +End + +ObjectCreationList LateBoundOCL + CreateObject + ObjectNames = LateOCLObject + End +End + +Object LateOCLObject +End + +ParticleSystem LateBoundParticle + SlaveSystem = LateSlaveParticle +End + +ParticleSystem LateSlaveParticle +End + +Object LateAudioConsumer + VoiceSelect = LateAudioEvent +End + +AudioEvent LateAudioEvent +End + +Object LateUpgradeConsumer + Behavior = WeaponSetUpgrade ModuleTag_LateStoredUpgrade + TriggeredBy = LateStoredUpgrade + End +End + +Upgrade LateStoredUpgrade +End diff --git a/crates/analysis/tests/spec/Map.spec.toml b/crates/analysis/tests/spec/Map.spec.toml index 615a126..b0d9ee2 100644 --- a/crates/analysis/tests/spec/Map.spec.toml +++ b/crates/analysis/tests/spec/Map.spec.toml @@ -29,6 +29,7 @@ severity = "warning" code = "map-forward-reference" on = "MapLateSet" nth = 1 +absent = true [[diag]] severity = "warning" @@ -48,3 +49,56 @@ severity = "warning" code = "map-projectile-object" on = "MapProjectile" nth = 2 + +[[diag]] +severity = "warning" +code = "map-forward-reference" +on = "LatePrereq" +nth = 1 + +[[diag]] +severity = "warning" +code = "map-forward-reference" +on = "LatePortrait" +nth = 1 + +[[diag]] +severity = "warning" +code = "map-forward-reference" +on = "ReskinParent" +nth = 1 + +[[diag]] +severity = "warning" +code = "map-forward-reference" +on = "LatePlayerSet" +nth = 1 +absent = true + +[[diag]] +severity = "warning" +code = "map-forward-reference" +on = "LateOCLObject" +nth = 1 +absent = true + +[[diag]] +severity = "warning" +code = "map-forward-reference" +on = "LateSlaveParticle" +nth = 1 +absent = true + +[[diag]] +severity = "warning" +code = "map-forward-reference" +on = "LateAudioEvent" +nth = 1 +absent = true + +[[diag]] +severity = "warning" +code = "map-forward-reference" +on = "LateStoredUpgrade" +nth = 1 +absent = true diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index 24bbd3d..512af7d 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -75,6 +75,9 @@ pub struct Backend { /// default: format-on-save rewriting a whole hand-indented game file is /// surprising, so formatting is opt-in per editor. format_enabled: 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, /// Whether the client supports snippet insertText (tab-stops, placeholders). /// Captured at `initialize` from the client's completion-item capabilities. snippet_support: OnceLock, @@ -132,6 +135,23 @@ 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, +) { + if !enabled { + diagnostics.retain(|diagnostic| diagnostic.code != "map-forward-reference"); + } +} + impl Backend { pub fn new(client: Client) -> Self { Backend { @@ -144,6 +164,7 @@ impl Backend { roots: Mutex::new(Vec::new()), encoding: OnceLock::new(), format_enabled: OnceLock::new(), + map_ordering_diagnostics: OnceLock::new(), base_roots: Mutex::new(Vec::new()), base_indexed_count: AtomicUsize::new(0), scan_finished: AtomicBool::new(false), @@ -170,6 +191,10 @@ impl Backend { self.format_enabled.get().copied().unwrap_or(false) } + fn map_ordering_diagnostics_enabled(&self) -> bool { + self.map_ordering_diagnostics.get().copied().unwrap_or(true) + } + fn next_semantic_id(&self) -> u64 { self.semantic_result_id .fetch_add(1, std::sync::atomic::Ordering::Relaxed) @@ -214,13 +239,14 @@ impl Backend { let enc = self.enc(); let lsp_diags: Vec = { let idx = self.index.read().ok(); - let diags = diagnostics::diagnose_with_cache( + 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)) @@ -526,7 +552,8 @@ impl LanguageServer for Backend { // requires a client restart (the VS Code extension does this // automatically). Shape: // `{ "format": {"enable": bool}, "schemaPath": "schema.json", - // "analysis": {"modelMemberStrictness": "compatible"}, + // "analysis": {"modelMemberStrictness": "compatible", + // "mapOrderingDiagnostics": true}, // "baseIniRoots": ["dir-or-big", ...], // "clientBaseIniHint": bool }`. let format_enabled = params @@ -538,6 +565,10 @@ impl LanguageServer for Backend { .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() @@ -923,13 +954,14 @@ impl LanguageServer for Backend { let range_span = zerosyntax_analysis::Span::new(start, end); let fixes = { let idx = self.index.read().ok(); - let diags = diagnostics::diagnose_with_cache( + let mut diags = diagnostics::diagnose_with_cache( &self.analyzer(), &parse, idx.as_deref(), Some(uri.as_str()), &mut cache, ); + filter_map_ordering_diagnostics(&mut diags, self.map_ordering_diagnostics_enabled()); let mut f = actions::fixes( &self.analyzer(), &parse, @@ -1378,6 +1410,35 @@ fn origin_copy_fixes( mod tests { use super::*; + #[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}}) + ))); + + let mut diagnostics = vec![ + zerosyntax_analysis::Diagnostic { + span: zerosyntax_analysis::Span::new(0, 1), + severity: zerosyntax_analysis::Severity::Warning, + code: "map-forward-reference", + message: String::new(), + }, + zerosyntax_analysis::Diagnostic { + span: zerosyntax_analysis::Span::new(0, 1), + severity: zerosyntax_analysis::Severity::Warning, + code: "map-projectile-object", + message: String::new(), + }, + ]; + filter_map_ordering_diagnostics(&mut diagnostics, false); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].code, "map-projectile-object"); + } + #[test] fn custom_schema_changes_analysis() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/syntax/src/ast.rs b/crates/syntax/src/ast.rs index a2bc749..1aac922 100644 --- a/crates/syntax/src/ast.rs +++ b/crates/syntax/src/ast.rs @@ -20,7 +20,9 @@ fn token_kind(t: &SyntaxToken) -> SyntaxKind { /// (i.e. on its header line, before any nested node). fn header_tokens(node: &SyntaxNode) -> impl Iterator + '_ { node.children_with_tokens() - .filter_map(move |el| el.into_token().filter(|t| !token_kind(t).is_trivia())) + .filter_map(|el| el.into_token()) + .take_while(|token| token_kind(token) != SyntaxKind::NEWLINE) + .filter(|token| !token_kind(token).is_trivia()) } fn header_value_tokens(node: &SyntaxNode) -> Vec { diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 9ebd112..90f55fb 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -27,7 +27,7 @@ file. Fix error-level syntax and schema problems rather than suppressing them. | `unknown-block` | A top-level block is not in the Generals INI schema. | | `overrides` | A map layer redefines an existing object-style definition. | | `duplicate-definition` | The same definition is declared more than once. | -| `map-forward-reference` | A map or solo INI references a definition declared later in that file, after the point where the game needs it. | +| `map-forward-reference` | A map or solo INI uses a definition before an engine parser that resolves it immediately. Move the named definition above the consumer. Stored names resolved later, such as an Object's `CommandSet`, may be declared afterward and do not warn. | | `map-projectile-object` | A weapon uses a map-defined projectile object that the game cannot resolve reliably. | | `unreachable-set` | A `WeaponSet` or `ArmorSet` cannot activate, or an upgrade module has no matching set. | | `unknown-field` | A field is not valid in the current block or module. | diff --git a/docs/language-server.md b/docs/language-server.md index 07a403e..8322469 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -85,7 +85,10 @@ symbols. { "format": { "enable": false }, "schemaPath": "C:/Mods/MyMod/schema.json", - "analysis": { "modelMemberStrictness": "compatible" }, + "analysis": { + "modelMemberStrictness": "compatible", + "mapOrderingDiagnostics": true + }, "baseIniRoots": [ "C:/Games/Zero Hour", "C:/Mods/MyMod/Data/INI", @@ -101,6 +104,8 @@ symbols. - `analysis.modelMemberStrictness` is `off`, `compatible` (member exists in any applicable model), or `strict` (member exists in every applicable model). It defaults to `compatible`. +- `analysis.mapOrderingDiagnostics` controls source-backed forward-order + warnings in `map.ini` and `solo.ini`. It defaults to `true`. - `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/README.md b/editors/vscode/README.md index ad91ef6..23f3af5 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -38,6 +38,7 @@ checks. | `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for map and model 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.trace.server` | `off` | Logs LSP traffic for troubleshooting. | diff --git a/editors/vscode/package.json b/editors/vscode/package.json index f3637e1..d4bfb35 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -81,6 +81,11 @@ "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." }, + "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." + }, "zerosyntax.trace.server": { "type": "string", "enum": [ diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 570cb47..d717d3d 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -42,6 +42,7 @@ export function activate(context: vscode.ExtensionContext) { schemaPath: setting("schema.path", ""), analysis: { modelMemberStrictness: setting("analysis.modelMemberStrictness", "compatible"), + mapOrderingDiagnostics: setting("analysis.mapOrderingDiagnostics", true), }, clientBaseIniHint: true, }), From d913405843a68b2061ebb10183eafbd7a2d5590e Mon Sep 17 00:00:00 2001 From: ViTeXFTW Date: Sat, 18 Jul 2026 11:06:35 +0200 Subject: [PATCH 6/6] chore: bump minor version --- Cargo.lock | 8 ++++---- Cargo.toml | 9 +++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e912565..196b478 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "zerosyntax-analysis" -version = "1.1.0" +version = "1.2.0" dependencies = [ "criterion", "rowan", @@ -1540,7 +1540,7 @@ dependencies = [ [[package]] name = "zerosyntax-schema" -version = "1.1.0" +version = "1.2.0" dependencies = [ "serde", "serde_json", @@ -1548,7 +1548,7 @@ dependencies = [ [[package]] name = "zerosyntax-server" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "clap", @@ -1568,7 +1568,7 @@ dependencies = [ [[package]] name = "zerosyntax-syntax" -version = "1.1.0" +version = "1.2.0" dependencies = [ "criterion", "logos", diff --git a/Cargo.toml b/Cargo.toml index 60779a6..e36a757 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.1.0" +version = "1.2.0" edition = "2021" license = "MIT" repository = "https://github.com/ViTeXFTW/ZeroSyntaxV2" @@ -34,7 +34,12 @@ dashmap = "6" # server (workspace indexing) walkdir = "2" -clap = { version = "4", default-features = false, features = ["std", "help", "usage", "error-context"] } +clap = { version = "4", default-features = false, features = [ + "std", + "help", + "usage", + "error-context", +] } # benches criterion = "0.8"