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/server/src/backend.rs b/crates/server/src/backend.rs index 0ff071e..ef8f5d4 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -666,6 +666,7 @@ impl LanguageServer for Backend { // automatically). Shape: // `{ "format": {"enable": bool}, "schemaPath": "schema.json", // "analysis": {"modelMemberStrictness": "compatible", + // "allowPercentagesWithoutSign": false, // "mapOrderingDiagnostics": true, "debounceMs": 250}, // "baseIniRoots": ["dir-or-big", ...], // "clientBaseIniHint": bool }`. @@ -701,6 +702,14 @@ impl LanguageServer for Backend { index.set_model_member_strictness(model_member_strictness); } + let allow_bare_percentages = params + .initialization_options + .as_ref() + .and_then(|v| v.get("analysis")) + .and_then(|v| v.get("allowPercentagesWithoutSign")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if let Some(path) = params .initialization_options .as_ref() @@ -716,6 +725,13 @@ impl LanguageServer for Backend { *current = error; } } + if 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); + } + } let base_roots = params .initialization_options diff --git a/docs/language-server.md b/docs/language-server.md index a8c0225..a083531 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 }, @@ -105,6 +106,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.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 diff --git a/editors/vscode/README.md b/editors/vscode/README.md index c6edf20..b146bc0 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -40,6 +40,7 @@ textures are offered using the engine-compatible `stem.tga` spelling. | `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.allowPercentagesWithoutSign` | `false` | Allows engine-compatible percentage values without a trailing `%`. | | `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. | diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 1925003..732708e 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.allowPercentagesWithoutSign": { + "type": "boolean", + "default": false, + "markdownDescription": "Allow engine-compatible percentage values without a trailing `%` sign. Changing this restarts the language server." + }, "zerosyntax.analysis.mapOrderingDiagnostics": { "type": "boolean", "default": true, diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index ccff5f3..62ce088 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); @@ -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,35 @@ export function activate(context: vscode.ExtensionContext) { .update("schema.path", selected[0].fsPath, vscode.ConfigurationTarget.Workspace); } }), + vscode.commands.registerCommand("zerosyntax.allowBarePercentages", async () => { + await vscode.workspace + .getConfiguration("zerosyntax") + .update(allowBarePercentagesSetting, true, vscode.ConfigurationTarget.Global); + }), + 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, + }; + return [action]; + }, + }, + { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] } + ), vscode.workspace.onDidOpenTextDocument((document) => { void maybeShowBaseIniRootsHint(document); }) diff --git a/editors/vscode/src/test/suite/smoke.test.ts b/editors/vscode/src/test/suite/smoke.test.ts index bd97adf..5ef23e1 100644 --- a/editors/vscode/src/test/suite/smoke.test.ts +++ b/editors/vscode/src/test/suite/smoke.test.ts @@ -14,7 +14,10 @@ suite("ZeroSyntax VS Code extension", () => { const uri = vscode.Uri.file(path.join(dir, "Smoke.ini")); 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 +49,19 @@ 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 + ); + assert.ok( + actions.some((action) => action.title === "Allow percentages without `%`"), + "expected the bare-percentage settings quick fix" + ); }); });