diff --git a/AGENTS.md b/AGENTS.md index f3f7033..5ea7526 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,11 +138,14 @@ schema.json ──embedded──▶ schema ──▶ analysis ──▶ server `WorkspaceIndex`. `convert.rs` does byte-offset ↔ LSP position mapping in the position encoding negotiated at `initialize` (UTF-8 when the client offers it, else the UTF-16 baseline). Advertises `semanticTokens` `full` - **and** `range`. Formatting is **opt-in**: the capability is advertised - only when `initializationOptions` carries `{"format": {"enable": true}}` - (the VS Code setting `zerosyntax.format.enable`, default off — real game - files are wildly hand-indented, so format-on-save must never fire - unasked). Phase-3 numbers (`docs/phase3-incremental.md`): keystroke on the + **and** `range`. Runtime settings arrive through initialization options and + `workspace/didChangeConfiguration`; analysis switches refresh open docs, + while schema/base-root changes replace the complete index and reparse only + when the schema changes. Formatting is **opt-in** and dynamically registered + when supported (the VS Code setting `zerosyntax.format.enable`, default off — + real game files are wildly hand-indented, so format-on-save must never fire + unasked). Only the executable-path setting requires a client restart. + Phase-3 numbers (`docs/phase3-incremental.md`): keystroke on the 61k-line ParticleSystem.ini ≈ 147 µs vs 44 ms full reparse. ### Two concepts worth understanding before editing diff --git a/crates/analysis/src/actions.rs b/crates/analysis/src/actions.rs index 75cb19f..1a91864 100644 --- a/crates/analysis/src/actions.rs +++ b/crates/analysis/src/actions.rs @@ -7,7 +7,7 @@ use zerosyntax_schema::{RefKind, ValueType}; use zerosyntax_syntax::ast::{Field, Module}; use zerosyntax_syntax::{Parse, SyntaxErrorKind, SyntaxKind, SyntaxNode, SyntaxToken}; -use crate::diagnostics::{pragma_rest, pragma_words, Diagnostic, Severity}; +use crate::diagnostics::{pragma_rest, pragma_words, Diagnostic}; use crate::model::scope_schema; use crate::{nav, Analyzer, Span, WorkspaceIndex}; @@ -203,12 +203,8 @@ fn diagnostic_fixes( } _ => {} } - // Suppress pragma: warnings and hints only; never errors, never the - // misspelled-suppression hint (suppressing it is self-defeating). - if d.severity != Severity::Error - && d.code != "unknown-suppression" - && suppress_seen.insert(d.code) - { + // Suppressing the misspelled-suppression hint is self-defeating. + if d.code != "unknown-suppression" && suppress_seen.insert(d.code) { suppress_fix(parse, text, d.code, out); } } @@ -429,7 +425,7 @@ fn stub_keyword<'a>(analyzer: &'a Analyzer, kind: RefKind) -> Option<&'a str> { } /// Offer to add `; zerosyntax-disable: ` at the top of the file (or -/// append to an existing pragma line) for warning/hint diagnostics. +/// append to an existing pragma line) for a diagnostic. fn suppress_fix(parse: &Parse, text: &str, code: &'static str, out: &mut Vec) { let root = parse.syntax(); let mut first_pragma: Option<(u32, bool)> = None; // (insert offset, has_any_codes) @@ -923,17 +919,14 @@ mod tests { } #[test] - fn suppress_fix_severity_and_dedupe_rules() { - // bad-bool is Error severity → no Suppress action. + fn suppress_fix_errors_and_dedupes() { + // Error diagnostics get the same Suppress action as warnings. let src_err = "Weapon W\n ScaleWeaponSpeed = Maybe\nEnd\n"; let fx_err = all_fixes(src_err); let has_suppress_for_error = fx_err .iter() .any(|f| f.title.contains("Suppress") && f.title.contains("bad-bool")); - assert!( - !has_suppress_for_error, - "errors must not get suppress: {fx_err:?}" - ); + assert!(has_suppress_for_error, "error missing suppress: {fx_err:?}"); // Two unresolved references with the same code → only one Suppress action. let src_dup = "Weapon W\n FireFX = NoSuchFX\n FireFX = NoSuchFX2\nEnd\n"; diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index f84c222..35304ca 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -1536,12 +1536,10 @@ impl<'a> Ctx<'a> { } } } - // Stricter than the engine (which reads a bare real): require the - // `%` sign, because `Armor = X 2` almost never means 2 percent. ValueType::Percent => { - let ok = tok - .text() - .strip_suffix('%') + let value = tok.text().strip_suffix('%'); + let ok = value + .or_else(|| self.analyzer.allow_bare_percentages().then_some(tok.text())) .is_some_and(|n| n.parse::().is_ok()); if !ok { self.error( @@ -1980,6 +1978,24 @@ mod tests { assert!(diags(src).is_empty(), "{:?}", diags(src)); } + #[test] + fn bare_percentages_are_opt_in() { + let src = "Armor A\n Armor = ARMOR_PIERCING 2.5\nEnd\n"; + assert!(codes(src).contains(&"bad-percent")); + + let mut analyzer = Analyzer::embedded(); + analyzer.set_allow_bare_percentages(true); + let parse = analyzer.parse(src); + assert!(!diagnose(&analyzer, &parse, None, None) + .iter() + .any(|d| d.code == "bad-percent")); + + let malformed = analyzer.parse("Armor A\n Armor = ARMOR_PIERCING nope\nEnd\n"); + assert!(diagnose(&analyzer, &malformed, None, None) + .iter() + .any(|d| d.code == "bad-percent")); + } + #[test] fn unknown_block_is_error() { assert!(codes("Wepon AK47\nEnd\n").contains(&"unknown-block")); diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index e367f85..9e57208 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -50,6 +50,7 @@ impl From for Span { /// it. Cheap to share; build once and reuse across documents. pub struct Analyzer { schema: Schema, + allow_bare_percentages: bool, block_by_name: HashMap, module_by_name: HashMap, value_set_by_id: HashMap, @@ -88,6 +89,7 @@ impl Analyzer { let openers = SchemaOpeners::from_schema(&schema); Analyzer { schema, + allow_bare_percentages: false, block_by_name, module_by_name, value_set_by_id, @@ -105,6 +107,15 @@ impl Analyzer { &self.schema } + /// Allow engine-compatible percentage values without a trailing `%`. + pub fn set_allow_bare_percentages(&mut self, allow: bool) { + self.allow_bare_percentages = allow; + } + + pub fn allow_bare_percentages(&self) -> bool { + self.allow_bare_percentages + } + /// Parse `src` using the schema-derived opener oracle. pub fn parse(&self, src: &str) -> Parse { parse(src, &self.openers) diff --git a/crates/analysis/tests/spec/QuickfixSuppress.ini b/crates/analysis/tests/spec/QuickfixSuppress.ini index 9da9bdf..66f6183 100644 --- a/crates/analysis/tests/spec/QuickfixSuppress.ini +++ b/crates/analysis/tests/spec/QuickfixSuppress.ini @@ -1,6 +1,6 @@ ; Quickfix test: suppress-in-file pragma. -; bad-bool fires an Error on "Maybe" → no Suppress action. +; bad-bool fires an Error on "Maybe" → Suppress action offered. Weapon QFSuppressWeapon ScaleWeaponSpeed = Maybe FireFX = UnknownFXRef diff --git a/crates/analysis/tests/spec/QuickfixSuppress.spec.toml b/crates/analysis/tests/spec/QuickfixSuppress.spec.toml index 932233d..c0d0728 100644 --- a/crates/analysis/tests/spec/QuickfixSuppress.spec.toml +++ b/crates/analysis/tests/spec/QuickfixSuppress.spec.toml @@ -1,4 +1,4 @@ -# "Maybe" fires bad-bool (Error) → no Suppress quickfix +# "Maybe" fires bad-bool (Error) → Suppress quickfix offered [[diag]] severity = "error" code = "bad-bool" @@ -6,7 +6,7 @@ on = "Maybe" [[action]] on = "Maybe" -not_offers = ["Suppress"] +offers = ["Suppress"] # UnknownFXRef fires unresolved-reference (Warning) → Suppress quickfix offered [[diag]] diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index 0ff071e..c1e7226 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -7,7 +7,7 @@ //! once per change batch; read-only requests reuse the cached parse. use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::time::Duration; @@ -49,24 +49,104 @@ struct DocumentState { const DEFAULT_ANALYSIS_DEBOUNCE_MS: u64 = 250; const MAX_ANALYSIS_DEBOUNCE_MS: u64 = 5_000; +const FORMATTING_REGISTRATION_ID: &str = "zerosyntax-formatting"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RuntimeSettings { + format_enabled: bool, + schema_path: String, + base_ini_roots: Vec, + model_member_strictness: ModelMemberStrictness, + allow_bare_percentages: bool, + map_ordering_diagnostics: bool, + debounce_ms: u64, +} + +impl Default for RuntimeSettings { + fn default() -> Self { + Self { + format_enabled: false, + schema_path: String::new(), + base_ini_roots: Vec::new(), + model_member_strictness: ModelMemberStrictness::Compatible, + allow_bare_percentages: false, + map_ordering_diagnostics: true, + debounce_ms: DEFAULT_ANALYSIS_DEBOUNCE_MS, + } + } +} + +impl RuntimeSettings { + fn from_value(value: Option<&serde_json::Value>) -> Self { + let Some(value) = value else { + return Self::default(); + }; + let value = value.get("zerosyntax").unwrap_or(value); + let analysis = value.get("analysis"); + let debounce_ms = + normalized_debounce_ms(analysis.and_then(|analysis| analysis.get("debounceMs"))); + Self { + format_enabled: value + .get("format") + .and_then(|format| format.get("enable")) + .and_then(|enabled| enabled.as_bool()) + .unwrap_or(false), + schema_path: value + .get("schemaPath") + .or_else(|| value.get("schema").and_then(|schema| schema.get("path"))) + .and_then(|path| path.as_str()) + .unwrap_or_default() + .trim() + .to_string(), + base_ini_roots: value + .get("baseIniRoots") + .and_then(|roots| roots.as_array()) + .map(|roots| { + roots + .iter() + .filter_map(|root| root.as_str()) + .filter(|root| !root.trim().is_empty()) + .map(PathBuf::from) + .collect() + }) + .unwrap_or_default(), + model_member_strictness: analysis + .and_then(|analysis| analysis.get("modelMemberStrictness")) + .and_then(|value| value.as_str()) + .map(|value| match value { + "off" => ModelMemberStrictness::Off, + "strict" => ModelMemberStrictness::Strict, + _ => ModelMemberStrictness::Compatible, + }) + .unwrap_or_default(), + allow_bare_percentages: analysis + .and_then(|analysis| analysis.get("allowPercentagesWithoutSign")) + .and_then(|value| value.as_bool()) + .unwrap_or(false), + map_ordering_diagnostics: analysis + .and_then(|analysis| analysis.get("mapOrderingDiagnostics")) + .and_then(|value| value.as_bool()) + .unwrap_or(true), + debounce_ms, + } + } +} -fn analysis_debounce(options: Option<&serde_json::Value>) -> Duration { - let value = options - .and_then(|v| v.get("analysis")) - .and_then(|v| v.get("debounceMs")); - let millis = value +fn normalized_debounce_ms(value: Option<&serde_json::Value>) -> u64 { + value .and_then(|v| { v.as_i64() .map(|n| n.clamp(0, MAX_ANALYSIS_DEBOUNCE_MS as i64) as u64) .or_else(|| v.as_u64().map(|n| n.min(MAX_ANALYSIS_DEBOUNCE_MS))) }) - .unwrap_or(DEFAULT_ANALYSIS_DEBOUNCE_MS); - Duration::from_millis(millis) + .unwrap_or(DEFAULT_ANALYSIS_DEBOUNCE_MS) } pub struct Backend { client: Client, - analyzer: RwLock>, + analyzer: Arc>>, + settings: Mutex, + reload_lock: tokio::sync::Mutex<()>, schema_error: Mutex>, /// Open documents, keyed by URI. docs: Arc>, @@ -75,9 +155,6 @@ pub struct Backend { index: Arc>, /// Workspace roots, captured at `initialize` and scanned in `initialized`. roots: Mutex>, - /// User-configured game/mod INI and asset roots. Entries may be directories or `.big` - /// archives; both seed definitions that map.ini/solo.ini can rely on. - base_roots: Mutex>, /// Number of base INI files indexed from configured base roots. base_indexed_count: AtomicUsize, /// Whether the initial workspace/base scan has completed at least once. @@ -88,14 +165,14 @@ pub struct Backend { client_base_ini_hint: OnceLock, /// Position encoding negotiated at `initialize` (UTF-16 until then). encoding: OnceLock, - /// Whether `textDocument/formatting` is enabled, from the client's - /// `initializationOptions` (`{"format": {"enable": true}}`). Off by - /// default: format-on-save rewriting a whole hand-indented game file is - /// surprising, so formatting is opt-in per editor. - format_enabled: OnceLock, + /// Whether `textDocument/formatting` is currently enabled. Off by default: + /// format-on-save rewriting a whole hand-indented game file is surprising, + /// so formatting is opt-in per editor. + format_enabled: AtomicBool, + formatting_dynamic_registration: OnceLock, /// Whether source-backed map/solo.ini forward-order warnings are emitted. /// Defaults on; clients can set `analysis.mapOrderingDiagnostics` to false. - map_ordering_diagnostics: OnceLock, + map_ordering_diagnostics: Arc, /// Whether the client supports snippet insertText (tab-stops, placeholders). /// Captured at `initialize` from the client's completion-item capabilities. snippet_support: OnceLock, @@ -104,9 +181,9 @@ pub struct Backend { progress_support: OnceLock, /// Delay after the latest edit before whole-document indexes and /// diagnostics refresh. Parsing and definition-name indexing stay eager. - analysis_debounce: OnceLock, + analysis_debounce_ms: AtomicU64, /// Monotonic id source for semantic-token results (delta bookkeeping). - semantic_result_id: std::sync::atomic::AtomicU64, + semantic_result_id: AtomicU64, } fn load_schema(path: &str) -> std::result::Result { @@ -156,14 +233,6 @@ fn is_map_layer_file(file: &str) -> bool { }) } -fn map_ordering_diagnostics_option(options: Option<&serde_json::Value>) -> bool { - options - .and_then(|value| value.get("analysis")) - .and_then(|analysis| analysis.get("mapOrderingDiagnostics")) - .and_then(|value| value.as_bool()) - .unwrap_or(true) -} - fn filter_map_ordering_diagnostics( diagnostics: &mut Vec, enabled: bool, @@ -177,17 +246,18 @@ fn filter_map_ordering_diagnostics( struct RefreshOptions { enc: PositionEnc, expected_version: Option, - map_ordering_diagnostics_enabled: bool, } async fn refresh_document( client: Client, - analyzer: Arc, + analyzer: Arc>>, docs: Arc>, index: Arc>, + map_ordering_diagnostics: Arc, uri: Url, options: RefreshOptions, ) { + let analyzer = analyzer.read().expect("analyzer lock poisoned").clone(); let Some((rope, parse, version)) = docs.get(&uri).and_then(|d| { if options .expected_version @@ -244,7 +314,10 @@ async fn refresh_document( Some(uri.as_str()), &mut cache, ); - filter_map_ordering_diagnostics(&mut diags, options.map_ordering_diagnostics_enabled); + filter_map_ordering_diagnostics( + &mut diags, + map_ordering_diagnostics.load(Ordering::Relaxed), + ); diags .iter() .map(|d| convert::to_lsp_diagnostic(&rope, d, options.enc)) @@ -269,24 +342,26 @@ impl Backend { pub fn new(client: Client) -> Self { Backend { client, - analyzer: RwLock::new(Arc::new(Analyzer::embedded())), + analyzer: Arc::new(RwLock::new(Arc::new(Analyzer::embedded()))), + settings: Mutex::new(RuntimeSettings::default()), + reload_lock: tokio::sync::Mutex::new(()), schema_error: Mutex::new(None), docs: Arc::new(DashMap::new()), virtual_files: DashMap::new(), index: Arc::new(RwLock::new(WorkspaceIndex::new())), roots: Mutex::new(Vec::new()), encoding: OnceLock::new(), - format_enabled: OnceLock::new(), - map_ordering_diagnostics: OnceLock::new(), - base_roots: Mutex::new(Vec::new()), + format_enabled: AtomicBool::new(false), + formatting_dynamic_registration: OnceLock::new(), + map_ordering_diagnostics: Arc::new(AtomicBool::new(true)), base_indexed_count: AtomicUsize::new(0), scan_finished: AtomicBool::new(false), base_roots_hint_shown: AtomicBool::new(false), client_base_ini_hint: OnceLock::new(), snippet_support: OnceLock::new(), progress_support: OnceLock::new(), - analysis_debounce: OnceLock::new(), - semantic_result_id: std::sync::atomic::AtomicU64::new(1), + analysis_debounce_ms: AtomicU64::new(DEFAULT_ANALYSIS_DEBOUNCE_MS), + semantic_result_id: AtomicU64::new(1), } } @@ -302,11 +377,11 @@ impl Backend { } fn format_enabled(&self) -> bool { - self.format_enabled.get().copied().unwrap_or(false) + self.format_enabled.load(Ordering::Relaxed) } fn map_ordering_diagnostics_enabled(&self) -> bool { - self.map_ordering_diagnostics.get().copied().unwrap_or(true) + self.map_ordering_diagnostics.load(Ordering::Relaxed) } fn next_semantic_id(&self) -> u64 { @@ -320,14 +395,14 @@ impl Backend { async fn refresh(&self, uri: &Url, expected_version: Option) { refresh_document( self.client.clone(), - self.analyzer(), + self.analyzer.clone(), self.docs.clone(), self.index.clone(), + self.map_ordering_diagnostics.clone(), uri.clone(), RefreshOptions { enc: self.enc(), expected_version, - map_ordering_diagnostics_enabled: self.map_ordering_diagnostics_enabled(), }, ) .await; @@ -336,16 +411,12 @@ impl Backend { fn schedule_refresh(&self, uri: Url, version: i32) { let client = self.client.clone(); - let analyzer = self.analyzer(); + let analyzer = self.analyzer.clone(); let docs = self.docs.clone(); let index = self.index.clone(); let enc = self.enc(); - let map_ordering_diagnostics_enabled = self.map_ordering_diagnostics_enabled(); - let delay = self - .analysis_debounce - .get() - .copied() - .unwrap_or_else(|| Duration::from_millis(DEFAULT_ANALYSIS_DEBOUNCE_MS)); + let map_ordering_diagnostics = self.map_ordering_diagnostics.clone(); + let delay = Duration::from_millis(self.analysis_debounce_ms.load(Ordering::Relaxed)); tokio::spawn(async move { tokio::time::sleep(delay).await; refresh_document( @@ -353,17 +424,153 @@ impl Backend { analyzer, docs, index, + map_ordering_diagnostics, uri, RefreshOptions { enc, expected_version: Some(version), - map_ordering_diagnostics_enabled, }, ) .await; }); } + async fn refresh_all(&self) { + let open: Vec = self + .docs + .iter() + .map(|document| document.key().clone()) + .collect(); + for uri in open { + self.refresh(&uri, None).await; + } + } + + fn clear_diagnostic_caches(&self) { + for mut document in self.docs.iter_mut() { + document.diag_cache = DiagnosticsCache::new(); + } + } + + async fn set_formatting_enabled(&self, enabled: bool) { + self.format_enabled.store(enabled, Ordering::Relaxed); + if !self + .formatting_dynamic_registration + .get() + .copied() + .unwrap_or(false) + { + return; + } + let result = if enabled { + self.client + .register_capability(vec![Registration { + id: FORMATTING_REGISTRATION_ID.into(), + method: "textDocument/formatting".into(), + register_options: Some(serde_json::json!({ + "documentSelector": [{"scheme": "file", "language": "generals-ini"}] + })), + }]) + .await + } else { + // The request guard is already false, so a client that fails to + // unregister can only receive a harmless null response. + self.client + .unregister_capability(vec![Unregistration { + id: FORMATTING_REGISTRATION_ID.into(), + method: "textDocument/formatting".into(), + }]) + .await + }; + if let Err(error) = result { + self.client + .log_message( + MessageType::ERROR, + format!("failed to update formatting capability: {error}"), + ) + .await; + } + } + + async fn apply_settings(&self, settings: RuntimeSettings) { + let _reload = self.reload_lock.lock().await; + let previous = { + let Ok(mut current) = self.settings.lock() else { + return; + }; + if *current == settings { + return; + } + let previous = current.clone(); + *current = settings.clone(); + previous + }; + + self.analysis_debounce_ms + .store(settings.debounce_ms, Ordering::Relaxed); + self.map_ordering_diagnostics + .store(settings.map_ordering_diagnostics, Ordering::Relaxed); + if previous.format_enabled != settings.format_enabled { + self.set_formatting_enabled(settings.format_enabled).await; + } + + let schema_changed = previous.schema_path != settings.schema_path; + let roots_changed = previous.base_ini_roots != settings.base_ini_roots; + let bare_changed = previous.allow_bare_percentages != settings.allow_bare_percentages; + let strictness_changed = + previous.model_member_strictness != settings.model_member_strictness; + let map_ordering_changed = + previous.map_ordering_diagnostics != settings.map_ordering_diagnostics; + + if schema_changed || roots_changed { + let (mut analyzer, warning) = if schema_changed { + if settings.schema_path.is_empty() { + (Analyzer::embedded(), None) + } else { + load_schema_or_embedded(&settings.schema_path) + } + } else if bare_changed { + (Analyzer::new(self.analyzer().schema().clone()), None) + } else { + self.scan_workspace(self.analyzer(), false).await; + self.refresh_all().await; + return; + }; + analyzer.set_allow_bare_percentages(settings.allow_bare_percentages); + let analyzer = Arc::new(analyzer); + if bare_changed && !schema_changed { + if let Ok(mut current) = self.analyzer.write() { + *current = analyzer.clone(); + } + } + if let Some(warning) = warning { + self.client + .show_message(MessageType::WARNING, warning) + .await; + } + self.scan_workspace(analyzer, schema_changed).await; + self.refresh_all().await; + return; + } + + if bare_changed { + let mut analyzer = Analyzer::new(self.analyzer().schema().clone()); + analyzer.set_allow_bare_percentages(settings.allow_bare_percentages); + if let Ok(mut current) = self.analyzer.write() { + *current = Arc::new(analyzer); + } + self.clear_diagnostic_caches(); + } + if strictness_changed { + if let Ok(mut index) = self.index.write() { + index.set_model_member_strictness(settings.model_member_strictness); + } + } + if bare_changed || strictness_changed || map_ordering_changed { + self.refresh_all().await; + } + } + async fn maybe_warn_missing_base_roots(&self, uri: &Url) { if !is_map_layer_file(uri.as_str()) { return; @@ -374,7 +581,11 @@ impl Backend { if self.base_indexed_count.load(Ordering::Relaxed) > 0 { return; } - let roots_empty = self.base_roots.lock().map(|r| r.is_empty()).unwrap_or(true); + let roots_empty = self + .settings + .lock() + .map(|settings| settings.base_ini_roots.is_empty()) + .unwrap_or(true); if roots_empty && self.client_base_ini_hint.get().copied().unwrap_or(false) { return; } @@ -400,18 +611,23 @@ impl Backend { /// client as `$/progress` (a status-bar spinner with a `done/total` /// counter in VS Code) so users can tell "still indexing" apart from /// "nothing was found". - async fn scan_workspace(&self) { + async fn scan_workspace(&self, analyzer: Arc, replace_analyzer: bool) { let progress_token = self.begin_scan_progress().await; let roots = self.roots.lock().map(|r| r.clone()).unwrap_or_default(); - let base_roots = self - .base_roots + let (base_roots, model_member_strictness) = self + .settings .lock() - .map(|r| r.clone()) + .map(|settings| { + ( + settings.base_ini_roots.clone(), + settings.model_member_strictness, + ) + }) .unwrap_or_default(); - let analyzer = self.analyzer(); // The blocking scan streams (done, total) over a channel; forward // each update as a progress report while waiting for the results. let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>(); + let scan_analyzer = analyzer.clone(); let handle = tokio::task::spawn_blocking(move || { let workspace_paths = collect_scan_paths(&roots); let base_paths = collect_scan_paths(&base_roots); @@ -428,8 +644,8 @@ impl Backend { let _ = tx.send((done, total)); } }; - let scanned = scan_files(&analyzer, &workspace_paths, &mut progress); - let base_scanned = scan_files(&analyzer, &base_paths, &mut progress); + let scanned = scan_files(&scan_analyzer, &workspace_paths, &mut progress); + let base_scanned = scan_files(&scan_analyzer, &base_paths, &mut progress); (scanned, base_scanned) }); while let Some((done, total)) = rx.recv().await { @@ -437,6 +653,18 @@ impl Backend { .await; } let (scanned, base_scanned) = handle.await.unwrap_or_default(); + + if replace_analyzer { + if let Ok(mut current) = self.analyzer.write() { + *current = analyzer.clone(); + } + for mut document in self.docs.iter_mut() { + document.parse = Arc::new(analyzer.parse(&document.text)); + document.diag_cache = DiagnosticsCache::new(); + document.last_semantic = None; + } + } + let base_ini_count = base_scanned .iter() .filter(|(_, _, _, _, _, _, models, assets, _)| models.is_empty() && assets.is_empty()) @@ -464,35 +692,49 @@ impl Backend { zerosyntax_analysis::index::AssetKind::Audio => (audio + 1, texture), zerosyntax_analysis::index::AssetKind::Texture => (audio, texture + 1), }); - // Don't overwrite index entries for already-open documents with stale - // disk content; `initialized` calls `refresh` for each open doc right - // after this returns, so they will populate the index from live text. + // Build the replacement off to the side so removed roots cannot leave + // stale definitions, assets, inheritance, models, or virtual files. let open: std::collections::HashSet = self .docs .iter() .map(|e| e.key().as_str().to_string()) .collect(); + let mut replacement = WorkspaceIndex::new(); + replacement.set_model_member_strictness(model_member_strictness); + self.virtual_files.clear(); + for (uri, defs, refs, tags, object_models, object_parents, models, assets, text) in + base_scanned.into_iter().chain(scanned) { - let Ok(mut idx) = self.index.write() else { - return; - }; - for (uri, defs, refs, tags, object_models, object_parents, models, assets, text) in - base_scanned.into_iter().chain(scanned) - { - if let Some(text) = text { - self.virtual_files.insert(uri.clone(), text); - } - if !open.contains(&uri) { - idx.set_file(&uri, defs); - idx.set_file_refs(&uri, refs); - idx.set_file_tags(&uri, tags); - idx.set_file_object_models(&uri, object_models); - idx.set_file_object_parents(&uri, object_parents); - idx.set_file_models(&uri, models); - idx.set_file_assets(&uri, assets); - } + if let Some(text) = text { + self.virtual_files.insert(uri.clone(), text); } + if !open.contains(&uri) { + replacement.set_file(&uri, defs); + replacement.set_file_refs(&uri, refs); + replacement.set_file_tags(&uri, tags); + replacement.set_file_object_models(&uri, object_models); + replacement.set_file_object_parents(&uri, object_parents); + replacement.set_file_models(&uri, models); + replacement.set_file_assets(&uri, assets); + } + } + for document in self.docs.iter() { + let uri = document.key(); + replacement.set_file( + uri.as_str(), + definitions_in(&analyzer, &document.parse, uri.as_str()), + ); + replacement.set_file_refs(uri.as_str(), references_in(&analyzer, &document.parse)); + replacement.set_file_tags(uri.as_str(), module_tags_in(&analyzer, &document.parse)); + replacement + .set_file_object_models(uri.as_str(), object_models_in(&analyzer, &document.parse)); + replacement.set_file_object_parents(uri.as_str(), object_parents_in(&document.parse)); + replacement.set_ini_string_keys(uri.as_str(), load_sibling_str_keys(uri)); + } + if let Ok(mut index) = self.index.write() { + *index = replacement; } + self.clear_diagnostic_caches(); self.end_scan_progress( progress_token, ini_total, @@ -661,79 +903,52 @@ impl LanguageServer for Backend { let (enc, enc_kind) = convert::negotiate_encoding(¶ms.capabilities); let _ = self.encoding.set(enc); - // Editor-facing settings arrive as `initializationOptions`; a change - // requires a client restart (the VS Code extension does this - // automatically). Shape: + // Editor-facing settings arrive as `initializationOptions`. Shape: // `{ "format": {"enable": bool}, "schemaPath": "schema.json", // "analysis": {"modelMemberStrictness": "compatible", + // "allowPercentagesWithoutSign": false, // "mapOrderingDiagnostics": true, "debounceMs": 250}, // "baseIniRoots": ["dir-or-big", ...], // "clientBaseIniHint": bool }`. - let format_enabled = params - .initialization_options - .as_ref() - .and_then(|v| v.get("format")) - .and_then(|f| f.get("enable")) - .and_then(|e| e.as_bool()) - .unwrap_or(false); - let _ = self.format_enabled.set(format_enabled); - - let map_ordering_diagnostics = - map_ordering_diagnostics_option(params.initialization_options.as_ref()); - let _ = self.map_ordering_diagnostics.set(map_ordering_diagnostics); - - let model_member_strictness = params - .initialization_options - .as_ref() - .and_then(|v| v.get("analysis")) - .and_then(|v| v.get("modelMemberStrictness")) - .and_then(|v| v.as_str()) - .map(|value| match value { - "off" => ModelMemberStrictness::Off, - "strict" => ModelMemberStrictness::Strict, - _ => ModelMemberStrictness::Compatible, - }) - .unwrap_or_default(); - let _ = self - .analysis_debounce - .set(analysis_debounce(params.initialization_options.as_ref())); + let settings = RuntimeSettings::from_value(params.initialization_options.as_ref()); + self.format_enabled + .store(settings.format_enabled, Ordering::Relaxed); + self.map_ordering_diagnostics + .store(settings.map_ordering_diagnostics, Ordering::Relaxed); + self.analysis_debounce_ms + .store(settings.debounce_ms, Ordering::Relaxed); if let Ok(mut index) = self.index.write() { - index.set_model_member_strictness(model_member_strictness); + index.set_model_member_strictness(settings.model_member_strictness); } - if let Some(path) = params - .initialization_options - .as_ref() - .and_then(|v| v.get("schemaPath")) - .and_then(|v| v.as_str()) - .filter(|path| !path.trim().is_empty()) - { - let (analyzer, error) = load_schema_or_embedded(path); + if !settings.schema_path.is_empty() { + let (mut analyzer, error) = load_schema_or_embedded(&settings.schema_path); + analyzer.set_allow_bare_percentages(settings.allow_bare_percentages); if let Ok(mut current) = self.analyzer.write() { *current = Arc::new(analyzer); } if let Ok(mut current) = self.schema_error.lock() { *current = error; } + } else if settings.allow_bare_percentages { + if let Ok(mut current) = self.analyzer.write() { + Arc::get_mut(&mut current) + .expect("analyzer shared before initialization completed") + .set_allow_bare_percentages(true); + } + } + if let Ok(mut current) = self.settings.lock() { + *current = settings.clone(); } - let base_roots = params - .initialization_options + let dynamic_formatting = params + .capabilities + .text_document .as_ref() - .and_then(|v| v.get("baseIniRoots")) - .and_then(|v| v.as_array()) - .map(|roots| { - roots - .iter() - .filter_map(|root| root.as_str()) - .filter(|root| !root.trim().is_empty()) - .map(PathBuf::from) - .collect::>() - }) - .unwrap_or_default(); - if let Ok(mut roots) = self.base_roots.lock() { - *roots = base_roots; - } + .and_then(|text| text.formatting.as_ref()) + .and_then(|formatting| formatting.dynamic_registration) + .unwrap_or(false); + let _ = self.formatting_dynamic_registration.set(dynamic_formatting); let client_base_ini_hint = params .initialization_options .as_ref() @@ -796,7 +1011,8 @@ impl LanguageServer for Backend { })), // Only advertised when opted in, so format-on-save in clients // never invokes a formatter the user didn't ask for. - document_formatting_provider: format_enabled.then_some(OneOf::Left(true)), + document_formatting_provider: (!dynamic_formatting && settings.format_enabled) + .then_some(OneOf::Left(true)), code_action_provider: Some(CodeActionProviderCapability::Options( CodeActionOptions { code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]), @@ -812,14 +1028,14 @@ impl LanguageServer for Backend { if let Some(error) = self.schema_error.lock().ok().and_then(|mut e| e.take()) { self.client.show_message(MessageType::WARNING, error).await; } - self.scan_workspace().await; + if self.format_enabled() { + self.set_formatting_enabled(true).await; + } + self.scan_workspace(self.analyzer(), false).await; // Re-publish diagnostics for any already-open docs now that the index // is populated (so cross-file references resolve). The cached parse is // still valid — only the index changed. - let open: Vec = self.docs.iter().map(|e| e.key().clone()).collect(); - for uri in open { - self.refresh(&uri, None).await; - } + self.refresh_all().await; let (ini, models, audio, textures) = { let idx = self.index.read().ok(); let models = idx @@ -861,6 +1077,11 @@ impl LanguageServer for Backend { Ok(()) } + async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { + self.apply_settings(RuntimeSettings::from_value(Some(¶ms.settings))) + .await; + } + async fn did_open(&self, params: DidOpenTextDocumentParams) { let uri = canonical_uri(params.text_document.uri); let text: Arc = params.text_document.text.into(); @@ -1554,13 +1775,13 @@ mod tests { #[test] fn map_ordering_diagnostics_can_be_disabled() { - assert!(map_ordering_diagnostics_option(None)); - assert!(map_ordering_diagnostics_option(Some( - &serde_json::json!({}) - ))); - assert!(!map_ordering_diagnostics_option(Some( - &serde_json::json!({"analysis": {"mapOrderingDiagnostics": false}}) - ))); + assert!(RuntimeSettings::from_value(None).map_ordering_diagnostics); + assert!( + !RuntimeSettings::from_value(Some(&serde_json::json!({ + "zerosyntax": {"analysis": {"mapOrderingDiagnostics": false}} + }))) + .map_ordering_diagnostics + ); let mut diagnostics = vec![ zerosyntax_analysis::Diagnostic { @@ -1583,27 +1804,32 @@ mod tests { #[test] fn analysis_debounce_defaults_overrides_and_clamps() { - assert_eq!(analysis_debounce(None), Duration::from_millis(250)); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 0}}))), - Duration::ZERO - ); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 400}}))), - Duration::from_millis(400) - ); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": -1}}))), - Duration::ZERO - ); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 9000}}))), - Duration::from_millis(5000) - ); - assert_eq!( - analysis_debounce(Some(&serde_json::json!({"analysis": {"debounceMs": 12.5}}))), - Duration::from_millis(250) - ); + assert_eq!(normalized_debounce_ms(None), 250); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(0))), 0); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(400))), 400); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(-1))), 0); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(9000))), 5000); + assert_eq!(normalized_debounce_ms(Some(&serde_json::json!(12.5))), 250); + } + + #[test] + fn runtime_settings_accept_startup_and_vscode_shapes() { + let startup = RuntimeSettings::from_value(Some(&serde_json::json!({ + "format": {"enable": true}, + "schemaPath": "schema.json", + "baseIniRoots": ["base"], + "analysis": {"modelMemberStrictness": "strict", "debounceMs": 9000} + }))); + let notification = RuntimeSettings::from_value(Some(&serde_json::json!({ + "zerosyntax": { + "format": {"enable": true}, + "schema": {"path": "schema.json"}, + "baseIniRoots": ["base"], + "analysis": {"modelMemberStrictness": "strict", "debounceMs": 9000} + } + }))); + assert_eq!(startup, notification); + assert_eq!(startup.debounce_ms, 5000); } #[test] diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index 3a1f2f2..a933838 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -13,6 +13,7 @@ import sys import threading import queue +import struct def frame(obj: dict) -> bytes: @@ -58,6 +59,17 @@ def main() -> int: workspace = pathlib.Path(tempfile.mkdtemp(prefix="zerosyntax-e2e-")) (workspace / "Images.INI").write_text("MappedImage TestScanImage\nEnd\n") + base = pathlib.Path(tempfile.mkdtemp(prefix="zerosyntax-e2e-base-")) + (base / "Base.ini").write_text("MappedImage HotBaseImage\nEnd\n") + (base / "HotSound.wav").write_bytes(b"") + (base / "HotTexture.dds").write_bytes(b"") + + def w3d_pivot(name): + payload = name.encode("ascii") + b"\0" * (60 - len(name)) + return struct.pack(" int: ) q: "queue.Queue" = queue.Queue() threading.Thread(target=reader, args=(proc.stdout, q), daemon=True).start() + server_requests = [] + indexing_begins = [] def send(obj): proc.stdin.write(frame(obj)) @@ -87,18 +101,45 @@ def wait_for(pred, what, timeout=15.0): break if msg is None: break + if msg.get("method") in { + "client/registerCapability", + "client/unregisterCapability", + "window/workDoneProgress/create", + } and "id" in msg: + server_requests.append(msg) + send({"jsonrpc": "2.0", "id": msg["id"], "result": None}) + if (msg.get("method") == "$/progress" + and msg.get("params", {}).get("value", {}).get("kind") == "begin"): + indexing_begins.append(msg) if pred(msg): return msg print(f"TIMEOUT waiting for {what}", file=sys.stderr) return None - # 1) initialize (with a workspace root so scan_workspace runs). Formatting - # is opt-in via initializationOptions; this session opts in so the - # formatting checks below run, and step 10 verifies the default is off. + runtime_settings = { + "format": {"enable": False}, + "baseIniRoots": [], + "schema": {"path": ""}, + "analysis": { + "modelMemberStrictness": "compatible", + "allowPercentagesWithoutSign": False, + "mapOrderingDiagnostics": True, + "debounceMs": 50, + }, + } + + def configure(): + send({"jsonrpc": "2.0", "method": "workspace/didChangeConfiguration", + "params": {"settings": {"zerosyntax": runtime_settings}}}) + + # 1) initialize with dynamic formatting and progress support. send({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"capabilities": {}, "workspaceFolders": None, "rootUri": root_uri, + "params": {"capabilities": { + "textDocument": {"formatting": {"dynamicRegistration": True}}, + "window": {"workDoneProgress": True}, + }, "workspaceFolders": None, "rootUri": root_uri, "initializationOptions": { - "format": {"enable": True}, + "format": {"enable": False}, "analysis": {"debounceMs": 50}, }}}) init = wait_for(lambda m: m.get("id") == 1 and "result" in m, "initialize result") @@ -110,6 +151,8 @@ def wait_for(pred, what, timeout=15.0): assert sync == 2, f"expected INCREMENTAL sync (2), got {sync!r}" # We offered no positionEncodings, so the server must stay on the baseline. assert caps.get("positionEncoding", "utf-16") == "utf-16", caps.get("positionEncoding") + assert "documentFormattingProvider" not in caps, \ + "dynamic clients must not receive a static formatting capability" print("OK: initialize advertised capabilities (incremental sync, utf-16)") send({"jsonrpc": "2.0", "method": "initialized", "params": {}}) @@ -402,10 +445,210 @@ def latest_burst_diag(message): assert "error" in bad, f"expected error for invalid name, got {bad}" print("OK: rename edits definition + references; invalid names rejected") - # 8) Phase-6 batch 2: semanticTokens delta, formatting, code actions. + # 8) Every runtime option hot-reloads without reopening documents. + percent_uri = "file:///test/percent.ini" + percent = open_doc(percent_uri, "Armor HotArmor\n Armor = ARMOR_PIERCING 2\nEnd\n") + assert "bad-percent" in [d.get("code") for d in percent["diagnostics"]] + runtime_settings["analysis"]["debounceMs"] = 300 + configure() + send({"jsonrpc": "2.0", "method": "textDocument/didChange", + "params": {"textDocument": {"uri": percent_uri, "version": 2}, + "contentChanges": [{"text": + "Armor HotArmor2\n Armor = ARMOR_PIERCING 2\nEnd\n"}]}}) + runtime_settings["analysis"]["allowPercentagesWithoutSign"] = True + configure() + percent = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == percent_uri, + "bare-percentage enable diagnostics", + ) + assert "bad-percent" not in [d.get("code") for d in percent["params"]["diagnostics"]] + delayed = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == percent_uri + and m["params"].get("version") == 2, + "pre-reload delayed diagnostics", + timeout=2.0, + ) + assert "bad-percent" not in [d.get("code") for d in delayed["params"]["diagnostics"]] + runtime_settings["analysis"]["allowPercentagesWithoutSign"] = False + configure() + percent = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == percent_uri, + "bare-percentage disable diagnostics", + ) + assert "bad-percent" in [d.get("code") for d in percent["params"]["diagnostics"]] + + map_uri = "file:///test/map.ini" + map_text = ("CommandSet HotSet\n 1 = Command_HotLate\nEnd\n" + "CommandButton Command_HotLate\n Command = UNIT_BUILD\nEnd\n") + map_diag = open_doc(map_uri, map_text) + assert "map-forward-reference" in [d.get("code") for d in map_diag["diagnostics"]] + runtime_settings["analysis"]["mapOrderingDiagnostics"] = False + configure() + map_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == map_uri, + "map-ordering disable diagnostics", + ) + assert "map-forward-reference" not in [d.get("code") for d in map_diag["params"]["diagnostics"]] + runtime_settings["analysis"]["mapOrderingDiagnostics"] = True + configure() + map_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == map_uri, + "map-ordering enable diagnostics", + ) + assert "map-forward-reference" in [d.get("code") for d in map_diag["params"]["diagnostics"]] + print("OK: percentage and map-ordering diagnostics hot-toggle") + + runtime_settings["analysis"]["debounceMs"] = 0 + configure() + percent = change_doc(percent_uri, 3, [{"text": + "Armor HotArmor3\n Armor = ARMOR_PIERCING 2\nEnd\n"}]) + assert percent.get("version") == 3 + print("OK: debounce hot-reloads and publishes the current document version") + + progress_before = len(indexing_begins) + runtime_settings["baseIniRoots"] = [str(base)] + configure() + wait_for( + lambda m: m.get("method") == "$/progress" + and m.get("params", {}).get("value", {}).get("kind") == "end", + "base-root indexing", + ) + assert len(indexing_begins) == progress_before + 1 + + asset_uri = "file:///test/hot-assets.ini" + open_doc(asset_uri, ("Object HotAssetObject\n ButtonImage = \nEnd\n" + "DialogEvent HotDialog\n Filename = \nEnd\n" + "MappedImage HotMapped\n Texture = \nEnd\n")) + request_id = 30 + + def completion_labels(doc_uri, line, character): + nonlocal request_id + request_id += 1 + send({"jsonrpc": "2.0", "id": request_id, + "method": "textDocument/completion", + "params": {"textDocument": {"uri": doc_uri}, + "position": {"line": line, "character": character}}}) + result = wait_for(lambda m: m.get("id") == request_id and "result" in m, + f"completion {request_id}") + items = result["result"] + if isinstance(items, dict): + items = items.get("items", []) + return [item["label"] for item in items] + + assert "HotBaseImage" in completion_labels(asset_uri, 1, 16) + assert "HotSound.wav" in completion_labels(asset_uri, 4, 13) + assert "HotTexture.tga" in completion_labels(asset_uri, 7, 12) + + model_uri = "file:///test/hot-model.ini" + model_text = ("Object HotModelObject\n" + " Draw = W3DModelDraw ModuleTag_Draw\n" + " DefaultConditionState\n" + " Model = A\n" + " Model = B\n" + " HideSubObject = Bone01\n" + " End\n End\nEnd\n") + model_diag = open_doc(model_uri, model_text) + assert "unknown-model-member" not in [d.get("code") for d in model_diag["diagnostics"]] + runtime_settings["analysis"]["modelMemberStrictness"] = "strict" + configure() + model_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == model_uri, + "strict model-member diagnostics", + ) + assert "unknown-model-member" in [d.get("code") for d in model_diag["params"]["diagnostics"]] + runtime_settings["analysis"]["modelMemberStrictness"] = "compatible" + configure() + model_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == model_uri, + "compatible model-member diagnostics", + ) + assert "unknown-model-member" not in [d.get("code") for d in model_diag["params"]["diagnostics"]] + print("OK: base roots add definitions/assets/models; strictness republishes") + + runtime_settings["baseIniRoots"] = [] + configure() + wait_for( + lambda m: m.get("method") == "$/progress" + and m.get("params", {}).get("value", {}).get("kind") == "end", + "base-root removal indexing", + ) + assert "HotBaseImage" not in completion_labels(asset_uri, 1, 16) + assert "HotSound.wav" not in completion_labels(asset_uri, 4, 13) + assert "HotTexture.tga" not in completion_labels(asset_uri, 7, 12) + print("OK: removing a base root removes definitions, audio, and textures") + + custom_uri = "file:///test/custom-open.ini" + custom = open_doc(custom_uri, "TestBlock HotCustom\n CustomOnly = Yes\nEnd\n") + assert "unknown-block" in [d.get("code") for d in custom["diagnostics"]] + custom_schema = pathlib.Path(__file__).parent / "fixtures" / "custom-schema.json" + runtime_settings["schema"]["path"] = str(custom_schema.resolve()) + configure() + custom = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == custom_uri, + "custom-schema diagnostics", + ) + assert "unknown-block" not in [d.get("code") for d in custom["params"]["diagnostics"]] + runtime_settings["schema"]["path"] = "" + configure() + custom = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == custom_uri, + "embedded-schema diagnostics", + ) + assert "unknown-block" in [d.get("code") for d in custom["params"]["diagnostics"]] + print("OK: schema hot-reload reparses already-open documents") + + runtime_settings["format"]["enable"] = True + configure() + registered = wait_for( + lambda m: m.get("method") == "client/registerCapability", + "dynamic formatting registration", + ) + assert registered["params"]["registrations"][0]["id"] == "zerosyntax-formatting" + runtime_settings["format"]["enable"] = False + configure() + unregistered = wait_for( + lambda m: m.get("method") == "client/unregisterCapability", + "dynamic formatting unregistration", + ) + assert unregistered["params"]["unregisterations"][0]["id"] == "zerosyntax-formatting" + send({"jsonrpc": "2.0", "id": 29, "method": "textDocument/formatting", + "params": {"textDocument": {"uri": percent_uri}, + "options": {"tabSize": 2, "insertSpaces": True}}}) + disabled = wait_for(lambda m: m.get("id") == 29, "disabled dynamic formatting") + assert disabled.get("result") is None + runtime_settings["format"]["enable"] = True + configure() + wait_for(lambda m: m.get("method") == "client/registerCapability", + "dynamic formatting re-registration") + + requests_before = len(server_requests) + progress_before = len(indexing_begins) + configure() + import time + time.sleep(0.25) + while not q.empty(): + pending = q.get_nowait() + assert pending.get("method") not in { + "client/registerCapability", "client/unregisterCapability" + }, pending + assert not (pending.get("method") == "$/progress" + and pending.get("params", {}).get("value", {}).get("kind") == "begin"), pending + assert len(server_requests) == requests_before + assert len(indexing_begins) == progress_before + print("OK: formatting hot-registers; identical settings are a no-op") + + # 9) Phase-6 batch 2: semanticTokens delta, formatting, code actions. assert caps["semanticTokensProvider"]["full"] == {"delta": True}, \ caps["semanticTokensProvider"]["full"] - assert caps.get("documentFormattingProvider"), "missing documentFormattingProvider" assert caps.get("codeActionProvider"), "missing codeActionProvider" # full (grab the resultId) -> edit -> delta must splice, not resend all. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index eb262fc..128b9b2 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -15,8 +15,8 @@ Separate multiple codes with spaces or commas. Multiple file-scope pragma lines accumulate. A misspelled code produces `unknown-suppression` instead of silently hiding nothing. -Suppressions are intended for warnings and hints that are valid for a specific -file. Fix error-level syntax and schema problems rather than suppressing them. +Suppressions can hide any diagnostic code for a specific file. Prefer fixing +error-level syntax and schema problems when possible. ## Diagnostic codes @@ -66,4 +66,4 @@ available fix. | Create a stub definition | A reference points to a missing definition that can be scaffolded safely. | | Remove an unreachable `WeaponSet` or `ArmorSet` | An upgrade-conditioned set can never activate. | | Insert a matching upgrade module or set | An object has only one side of an upgrade-conditioned weapon or armor setup. | -| Suppress a code in this file | A warning or hint is intentional for the current file. | +| Suppress a code in this file | A diagnostic is intentional for the current file. | diff --git a/docs/language-server.md b/docs/language-server.md index a8c0225..ed15c88 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -87,6 +87,7 @@ symbols. "schemaPath": "C:/Mods/MyMod/schema.json", "analysis": { "modelMemberStrictness": "compatible", + "allowPercentagesWithoutSign": false, "mapOrderingDiagnostics": true, "debounceMs": 250 }, @@ -98,13 +99,15 @@ symbols. } ``` -- `format.enable` controls whether the server advertises document formatting. - It defaults to `false`. +- `format.enable` controls document formatting. It defaults to `false` and is + dynamically registered when the client supports it. - `schemaPath` points to a custom schema JSON file. Unreadable or invalid files produce a warning and fall back to the built-in schema. - `analysis.modelMemberStrictness` is `off`, `compatible` (member exists in any applicable model), or `strict` (member exists in every applicable model). It defaults to `compatible`. +- `analysis.allowPercentagesWithoutSign` accepts engine-compatible bare numbers + in percentage fields. It defaults to `false`, requiring the trailing `%`. - `analysis.mapOrderingDiagnostics` controls source-backed forward-order warnings in `map.ini` and `solo.ini`. It defaults to `true`. - `analysis.debounceMs` waits this many milliseconds after the latest edit @@ -119,7 +122,17 @@ symbols. avoid warnings caused by a partial asset index. INI definitions are treated as loaded before `map.ini` and `solo.ini`. -Restart the language server after changing initialization options. +The same settings can be sent at runtime through +`workspace/didChangeConfiguration`, either directly or nested under +`{"zerosyntax": ...}`. Analysis, debounce, and formatting changes apply +immediately. Schema and base-root changes rebuild the complete index, keep +filesystem scanning on a blocking worker, and report indexing progress. +Identical settings are ignored. + +Clients without dynamic formatting registration keep their startup formatting +capability. If such a client starts with formatting disabled, it must restart +to expose formatting; all other settings still hot-reload. Only selecting a +different server executable inherently requires a new process. ## Supported LSP features diff --git a/editors/vscode/README.md b/editors/vscode/README.md index c6edf20..b2a91ba 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -37,17 +37,23 @@ textures are offered using the engine-compatible `stem.tga` spelling. | Setting | Default | Purpose | | --- | --- | --- | -| `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for INI and game-asset checks. | -| `zerosyntax.schema.path` | empty | Custom schema JSON; invalid files fall back to the built-in schema. | -| `zerosyntax.analysis.modelMemberStrictness` | `compatible` | Disables member warnings, accepts any applicable model, or requires every model. | -| `zerosyntax.analysis.mapOrderingDiagnostics` | `true` | Warns about source-proven forward-order problems in `map.ini` and `solo.ini`. | -| `zerosyntax.format.enable` | `false` | Enables indentation formatting. Changing it restarts the server. | -| `zerosyntax.server.path` | empty | Uses a custom `zerosyntax-lsp` binary instead of the bundled one. | +| `zerosyntax.baseIniRoots` | `[]` | Base game/mod directories and `.big` archives used for INI and game-asset checks; changes reindex. | +| `zerosyntax.schema.path` | empty | Custom schema JSON; changes reparse and reindex, with invalid files falling back to the built-in schema. | +| `zerosyntax.analysis.modelMemberStrictness` | `compatible` | Disables member warnings, accepts any applicable model, or requires every model; applies immediately. | +| `zerosyntax.analysis.allowPercentagesWithoutSign` | `false` | Allows engine-compatible percentage values without a trailing `%`; applies immediately. | +| `zerosyntax.analysis.mapOrderingDiagnostics` | `true` | Warns about source-proven forward-order problems in `map.ini` and `solo.ini`; applies immediately. | +| `zerosyntax.analysis.debounceMs` | `250` | Delay before diagnostics/index refresh after typing; applies to future edits immediately. | +| `zerosyntax.format.enable` | `false` | Enables indentation formatting immediately when the client supports dynamic registration. | +| `zerosyntax.server.path` | empty | Uses a custom `zerosyntax-lsp` binary instead of the bundled one; changing it restarts the server. | | `zerosyntax.trace.server` | `off` | Logs LSP traffic for troubleshooting. | Formatting is intentionally off by default. Enable it only when you want **Format Document** or format-on-save to normalize indentation. +Runtime settings reload without restarting. Schema and base-root changes show +indexing progress because they rebuild workspace state; only changing the +server executable path requires a normal VS Code language-server restart. + ## INI file association The extension associates `.ini` files with **Generals INI**. If your workspace diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 1925003..8e4a1fb 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -55,12 +55,12 @@ "zerosyntax.server.path": { "type": "string", "default": "", - "markdownDescription": "Absolute path to the `zerosyntax-lsp` server binary. If empty, the extension uses the bundled binary under `server/`, then falls back to `zerosyntax-lsp` on your PATH." + "markdownDescription": "Absolute path to the `zerosyntax-lsp` server binary. If empty, the extension uses the bundled binary under `server/`, then falls back to `zerosyntax-lsp` on your PATH. This is the only ZeroSyntax setting whose change restarts the language server." }, "zerosyntax.format.enable": { "type": "boolean", "default": false, - "markdownDescription": "Enable document formatting (indentation normalization). When off — the default — the server does not advertise the formatting capability, so `#editor.formatOnSave#` will not invoke it for Generals INI files. Changing this restarts the language server." + "markdownDescription": "Enable document formatting (indentation normalization). When off — the default — `#editor.formatOnSave#` will not invoke it for Generals INI files." }, "zerosyntax.baseIniRoots": { "type": "array", @@ -68,30 +68,35 @@ "items": { "type": "string" }, - "markdownDescription": "Directories or `.big` archives containing base game/mod INI files and game assets. INI definitions are treated as already loaded before `map.ini`/`solo.ini`; W3D, WAV/MP3, and TGA/DDS assets power completions and diagnostics. Configure all loaded roots to avoid partial-index warnings. Changing this restarts the language server." + "markdownDescription": "Directories or `.big` archives containing base game/mod INI files and game assets. INI definitions are treated as already loaded before `map.ini`/`solo.ini`; W3D, WAV/MP3, and TGA/DDS assets power completions and diagnostics. Configure all loaded roots to avoid partial-index warnings. Changing this reindexes in the background." }, "zerosyntax.schema.path": { "type": "string", "default": "", - "markdownDescription": "Path to a custom ZeroSyntax schema JSON file. Use **ZeroSyntax: Select Custom Schema** to choose one. Invalid or unreadable files fall back to the built-in schema. Changing this restarts the language server." + "markdownDescription": "Path to a custom ZeroSyntax schema JSON file. Use **ZeroSyntax: Select Custom Schema** to choose one. Invalid or unreadable files fall back to the built-in schema. Changing this reparses open files and reindexes in the background." }, "zerosyntax.analysis.modelMemberStrictness": { "type": "string", "enum": ["off", "compatible", "strict"], "default": "compatible", - "markdownDescription": "Model-member diagnostics: off disables warnings, compatible accepts a bone/subobject present in any applicable model, and strict requires it in every applicable model. Changing this restarts the language server." + "markdownDescription": "Model-member diagnostics: off disables warnings, compatible accepts a bone/subobject present in any applicable model, and strict requires it in every applicable model." + }, + "zerosyntax.analysis.allowPercentagesWithoutSign": { + "type": "boolean", + "default": false, + "markdownDescription": "Allow engine-compatible percentage values without a trailing `%` sign." }, "zerosyntax.analysis.mapOrderingDiagnostics": { "type": "boolean", "default": true, - "markdownDescription": "Warn when map.ini or solo.ini uses a definition before an engine parser resolves it. Changing this restarts the language server." + "markdownDescription": "Warn when map.ini or solo.ini uses a definition before an engine parser resolves it." }, "zerosyntax.analysis.debounceMs": { "type": "integer", "default": 250, "minimum": 0, "maximum": 5000, - "markdownDescription": "Milliseconds to wait after typing before refreshing whole-document indexes and diagnostics. Parsing and completions remain immediate. Use `0` to refresh as soon as possible. Changing this restarts the language server." + "markdownDescription": "Milliseconds to wait after typing before refreshing whole-document indexes and diagnostics. Parsing and completions remain immediate. Use `0` to refresh as soon as possible." }, "zerosyntax.trace.server": { "type": "string", diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index ccff5f3..0ae2547 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -10,6 +10,7 @@ import { let client: LanguageClient | undefined; let baseIniRootsHintShown = false; +const allowBarePercentagesSetting = "analysis.allowPercentagesWithoutSign"; export function activate(context: vscode.ExtensionContext) { const serverPath = resolveServerPath(context); @@ -31,9 +32,9 @@ export function activate(context: vscode.ExtensionContext) { synchronize: { // Re-index when any .ini in the workspace changes on disk. fileEvents: vscode.workspace.createFileSystemWatcher("**/*.ini"), + configurationSection: "zerosyntax", }, - // Evaluated on every (re)start, so a settings-triggered restart picks up - // the current values. The server reads these once at `initialize`. + // Keep startup compatibility for clients that do not synchronize settings. initializationOptions: () => ({ format: { enable: setting("format.enable", false), @@ -42,6 +43,7 @@ export function activate(context: vscode.ExtensionContext) { schemaPath: setting("schema.path", ""), analysis: { modelMemberStrictness: setting("analysis.modelMemberStrictness", "compatible"), + allowPercentagesWithoutSign: setting(allowBarePercentagesSetting, false), mapOrderingDiagnostics: setting("analysis.mapOrderingDiagnostics", true), debounceMs: setting("analysis.debounceMs", 250), }, @@ -83,6 +85,41 @@ export function activate(context: vscode.ExtensionContext) { .update("schema.path", selected[0].fsPath, vscode.ConfigurationTarget.Workspace); } }), + vscode.commands.registerCommand("zerosyntax.allowBarePercentages", async (uri?: vscode.Uri) => { + const configuration = vscode.workspace.getConfiguration("zerosyntax", uri); + const inspected = configuration.inspect(allowBarePercentagesSetting); + const target = inspected?.workspaceFolderValue !== undefined + ? vscode.ConfigurationTarget.WorkspaceFolder + : inspected?.workspaceValue !== undefined + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + await configuration.update(allowBarePercentagesSetting, true, target); + }), + vscode.languages.registerCodeActionsProvider( + { scheme: "file", language: "generals-ini" }, + { + provideCodeActions(document, _range, actionContext) { + const diagnostics = actionContext.diagnostics.filter( + (diagnostic) => diagnostic.code === "bad-percent" + ); + if (diagnostics.length === 0) { + return []; + } + const action = new vscode.CodeAction( + "Allow percentages without `%`", + vscode.CodeActionKind.QuickFix + ); + action.diagnostics = diagnostics; + action.command = { + command: "zerosyntax.allowBarePercentages", + title: action.title, + arguments: [document.uri], + }; + return [action]; + }, + }, + { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] } + ), vscode.workspace.onDidOpenTextDocument((document) => { void maybeShowBaseIniRootsHint(document); }) @@ -91,11 +128,11 @@ export function activate(context: vscode.ExtensionContext) { void maybeShowBaseIniRootsHint(editor.document); } - // Server settings (initializationOptions, server path) are read once at - // startup, so any zerosyntax.* change needs a clean restart to apply. + // Only another executable requires another process; synchronized runtime + // settings are applied by the existing client configuration notification. context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("zerosyntax")) { + if (e.affectsConfiguration("zerosyntax.server.path")) { void client?.restart(); } }) diff --git a/editors/vscode/src/test/runTest.ts b/editors/vscode/src/test/runTest.ts index 15b9a7a..d33eff6 100644 --- a/editors/vscode/src/test/runTest.ts +++ b/editors/vscode/src/test/runTest.ts @@ -1,15 +1,30 @@ +import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; import { runTests } from "@vscode/test-electron"; async function main() { const extensionDevelopmentPath = path.resolve(__dirname, "../../.."); const extensionTestsPath = path.resolve(__dirname, "suite/index"); + const testWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "zerosyntax-vscode-")); + const testWorkspaceFile = path.join(testWorkspace, "ZeroSyntax.code-workspace"); + fs.writeFileSync( + testWorkspaceFile, + JSON.stringify({ + folders: [{ path: "." }], + settings: { "zerosyntax.analysis.allowPercentagesWithoutSign": false }, + }) + ); - await runTests({ - extensionDevelopmentPath, - extensionTestsPath, - launchArgs: ["--disable-extensions"], - }); + try { + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [testWorkspaceFile, "--disable-extensions"], + }); + } finally { + fs.rmSync(testWorkspace, { recursive: true, force: true }); + } } main().catch((err) => { diff --git a/editors/vscode/src/test/suite/smoke.test.ts b/editors/vscode/src/test/suite/smoke.test.ts index bd97adf..69c83bd 100644 --- a/editors/vscode/src/test/suite/smoke.test.ts +++ b/editors/vscode/src/test/suite/smoke.test.ts @@ -1,6 +1,5 @@ import * as assert from "assert"; import * as fs from "fs"; -import * as os from "os"; import * as path from "path"; import * as vscode from "vscode"; @@ -10,11 +9,22 @@ suite("ZeroSyntax VS Code extension", () => { assert.ok(serverPath, "ZEROSYNTAX_LSP_PATH must point at ZeroSyntax-lsp"); assert.ok(fs.existsSync(serverPath), `${serverPath} does not exist`); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "zerosyntax-vscode-")); + const workspace = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspace, "expected the test launcher to open a workspace"); + const dir = workspace.uri.fsPath; const uri = vscode.Uri.file(path.join(dir, "Smoke.ini")); + const configuration = vscode.workspace.getConfiguration("zerosyntax", uri); + assert.strictEqual( + configuration.inspect("analysis.allowPercentagesWithoutSign")?.workspaceValue, + false, + "expected the test workspace to disable bare percentages" + ); await vscode.workspace.fs.writeFile( uri, - Buffer.from("Weapon SmokeGun\n ScaleWeaponSpeed = Maybe\n \nEnd\n") + Buffer.from( + "Weapon SmokeGun\n ScaleWeaponSpeed = Maybe\n \nEnd\n" + + "Armor SmokeArmor\n Armor = ARMOR_PIERCING 2\nEnd\n" + ) ); const document = await vscode.workspace.openTextDocument(uri); @@ -46,6 +56,30 @@ suite("ZeroSyntax VS Code extension", () => { labels.includes("PrimaryDamage"), `expected PrimaryDamage completion, got ${labels.slice(0, 10).join(", ")}` ); + + const percentDiagnostic = diagnostics.find((diag) => diag.code === "bad-percent"); + assert.ok(percentDiagnostic, "expected a bad-percent diagnostic"); + const actions = await vscode.commands.executeCommand<(vscode.CodeAction | vscode.Command)[]>( + "vscode.executeCodeActionProvider", + uri, + percentDiagnostic.range, + vscode.CodeActionKind.QuickFix.value + ); + const allow = actions.find( + (action) => action.title === "Allow percentages without `%`" + ); + assert.ok(allow, "expected the bare-percentage settings quick fix"); + await vscode.commands.executeCommand("zerosyntax.allowBarePercentages", uri); + assert.strictEqual( + configuration.inspect("analysis.allowPercentagesWithoutSign")?.workspaceValue, + true, + "expected the quick fix to override the workspace setting" + ); + await waitFor( + () => vscode.languages.getDiagnostics(uri), + (items) => items.every((diag) => diag.code !== "bad-percent"), + "hot-reloaded bare-percentage setting" + ); }); });