diff --git a/Cargo.lock b/Cargo.lock index f39e5e9..da8fbe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,6 +1553,7 @@ dependencies = [ "anyhow", "clap", "dashmap 6.2.1", + "percent-encoding", "ropey", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index bd6272b..6f87bf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ tower-lsp = "0.20" tokio = { version = "1", features = ["full"] } ropey = "1" dashmap = "6" +percent-encoding = "2" # server (workspace indexing) walkdir = "2" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 0fead88..24d9195 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -16,6 +16,7 @@ tower-lsp.workspace = true tokio.workspace = true ropey.workspace = true dashmap.workspace = true +percent-encoding.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index d7dcc31..ef7b052 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -12,6 +12,7 @@ use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::time::Duration; use dashmap::DashMap; +use percent_encoding::percent_decode_str; use ropey::Rope; use serde::Deserialize; use tower_lsp::lsp_types::*; @@ -220,7 +221,7 @@ pub struct VirtualFileParams { /// makes the same file land in the `WorkspaceIndex` under two different keys, /// so every definition appears duplicated. Round-tripping through the file-path /// canonicalises both percent-encoding and drive-letter casing. Non-`file:` -/// schemes are returned unchanged. +/// schemes other than `big:` are returned unchanged. fn canonical_uri(uri: Url) -> Url { if uri.scheme() == "file" { if let Ok(path) = uri.to_file_path() { @@ -228,6 +229,22 @@ fn canonical_uri(uri: Url) -> Url { return canonical; } } + } else if uri.scheme() == "big" { + let Ok(mut path) = percent_decode_str(uri.path()) + .decode_utf8() + .map(|path| path.into_owned()) + else { + return uri; + }; + if path.as_bytes().get(1).is_some_and(u8::is_ascii_lowercase) + && path.as_bytes().get(2) == Some(&b':') + { + let drive = char::from(path.as_bytes()[1].to_ascii_uppercase()).to_string(); + path.replace_range(1..2, &drive); + } + let mut canonical = Url::parse("big:///").expect("static BIG URI is valid"); + canonical.set_path(&path); + return canonical; } uri } @@ -826,7 +843,8 @@ impl Backend { /// Resolve a URI's text to a rope, preferring open documents and falling /// back to disk (for go-to-definition into unopened files). fn rope_for(&self, uri: &Url) -> Option { - if let Some(doc) = self.docs.get(uri) { + let uri = canonical_uri(uri.clone()); + if let Some(doc) = self.docs.get(&uri) { return Some(doc.rope.clone()); } if uri.scheme() == "big" { @@ -840,9 +858,12 @@ impl Backend { } pub async fn read_virtual_file(&self, params: VirtualFileParams) -> Result> { + let Some(uri) = Url::parse(¶ms.uri).ok().map(canonical_uri) else { + return Ok(None); + }; Ok(self .virtual_files - .get(¶ms.uri) + .get(uri.as_str()) .map(|text| text.to_string())) } @@ -1933,11 +1954,45 @@ mod tests { } #[test] - fn canonical_uri_pass_through_non_file() { + fn canonical_uri_pass_through_other_schemes() { let u = Url::parse("untitled:///buffer").unwrap(); assert_eq!(canonical_uri(u.clone()), u); } + #[test] + fn canonical_uri_normalises_big_uri_path() { + let scanner = + Url::parse("big:///C:/Game%20Folder/Base%23.big!/Data/INI/Object.ini").unwrap(); + for client in [ + "big:///C%3A/Game%20Folder/Base%23.big!/Data/INI/Object.ini", + "big:/c%3A/Game%20Folder/Base%23.big%21/Data/INI/Object.ini", + ] { + assert_eq!(canonical_uri(Url::parse(client).unwrap()), scanner); + } + } + + #[test] + fn canonical_uri_keeps_scanner_big_uri() { + let scanner = + Url::parse("big:///C:/Game%20Folder/Base%23.big!/Data/INI/Object.ini").unwrap(); + assert_eq!(canonical_uri(scanner.clone()), scanner); + } + + #[test] + fn malformed_or_unknown_big_uri_has_no_virtual_content() { + let files = DashMap::new(); + files.insert( + "big:///C:/Game%20Folder/Base.big!/Data/INI/Object.ini".to_string(), + Arc::::from("Object Known\nEnd\n"), + ); + let malformed = Url::parse("big:///C%3A/Game%FF/Base.big!/Data/INI/Object.ini").unwrap(); + let unknown = + Url::parse("big:///C%3A/Game%20Folder/Base.big!/Data/INI/Unknown.ini").unwrap(); + assert_eq!(canonical_uri(malformed.clone()), malformed); + assert!(!files.contains_key(canonical_uri(malformed).as_str())); + assert!(!files.contains_key(canonical_uri(unknown).as_str())); + } + #[test] fn detects_map_layer_filenames() { assert!(is_map_layer_file("file:///C:/Maps/Foo/map.ini")); @@ -1948,7 +2003,7 @@ mod tests { #[test] fn scans_ini_w3d_audio_and_texture_from_big_archive() { let dir = std::env::temp_dir(); - let path = dir.join(format!("zerosyntax-test-{}.big", std::process::id())); + let path = dir.join(format!("zerosyntax test #-{}.big", std::process::id())); let entries: Vec<(&str, &[u8])> = vec![ ("Data\\INI\\Test.ini", b"Object BigArchiveObject\nEnd\n"), ("Art\\Good.w3d", b""), diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index a933838..35be963 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -14,6 +14,7 @@ import threading import queue import struct +import urllib.parse def frame(obj: dict) -> bytes: @@ -70,6 +71,33 @@ def w3d_pivot(name): (base / "A.w3d").write_bytes(w3d_pivot("Bone01")) (base / "B.w3d").write_bytes(w3d_pivot("Other")) + archived_text = ( + "CommandButton ArchivedButton\n" + " Command = UNIT_BUILD\n" + "End\n" + "CommandSet ArchivedSet\n" + " 1 = ArchivedButton\n" + "End\n" + ) + archive = base / "Base Cache #.big" + + def write_big(path, entries): + data_offset = 0x10 + sum(8 + len(name.encode("ascii")) + 1 for name in entries) + archive_size = data_offset + sum(len(data) for data in entries.values()) + data = bytearray(b"BIGF") + data.extend(struct.pack(">III", archive_size, len(entries), 0)) + offset = data_offset + for name, content in entries.items(): + encoded_name = name.encode("ascii") + data.extend(struct.pack(">II", offset, len(content))) + data.extend(encoded_name + b"\0") + offset += len(content) + for content in entries.values(): + data.extend(content) + path.write_bytes(data) + + archive_entry = "Data/INI/Archived.ini" + write_big(archive, {archive_entry: archived_text.encode("utf-8")}) root_uri = workspace.as_uri() # vscode-languageclient appends this conventional transport flag. The @@ -511,14 +539,16 @@ def latest_burst_diag(message): print("OK: debounce hot-reloads and publishes the current document version") progress_before = len(indexing_begins) - runtime_settings["baseIniRoots"] = [str(base)] + runtime_settings["baseIniRoots"] = [str(base), str(archive)] configure() wait_for( lambda m: m.get("method") == "$/progress" and m.get("params", {}).get("value", {}).get("kind") == "end", "base-root indexing", ) - assert len(indexing_begins) == progress_before + 1 + assert len(indexing_begins) == progress_before + 1, ( + f"expected one base-root scan, got {len(indexing_begins) - progress_before}" + ) asset_uri = "file:///test/hot-assets.ini" open_doc(asset_uri, ("Object HotAssetObject\n ButtonImage = \nEnd\n" @@ -540,9 +570,105 @@ def completion_labels(doc_uri, line, character): items = items.get("items", []) return [item["label"] for item in items] - assert "HotBaseImage" in completion_labels(asset_uri, 1, 16) - assert "HotSound.wav" in completion_labels(asset_uri, 4, 13) - assert "HotTexture.tga" in completion_labels(asset_uri, 7, 12) + assert "HotBaseImage" in completion_labels(asset_uri, 1, 16), \ + "loose base INI definition missing" + assert "HotSound.wav" in completion_labels(asset_uri, 4, 13), \ + "base audio asset missing" + assert "HotTexture.tga" in completion_labels(asset_uri, 7, 12), \ + "base texture asset missing" + + archive_path = archive.as_posix() + if not archive_path.startswith("/"): + archive_path = "/" + archive_path + archived_uri = "big://" + urllib.parse.quote( + f"{archive_path}!/{archive_entry}", safe="/:!" + ) + send({"jsonrpc": "2.0", "id": 34, "method": "zerosyntax/readVirtualFile", + "params": {"uri": archived_uri}}) + canonical_virtual_file = wait_for( + lambda m: m.get("id") == 34 and "result" in m, + "canonical virtual file", + ) + assert canonical_virtual_file["result"] == archived_text, ( + archived_uri, canonical_virtual_file["result"] + ) + workspace_ref_uri = "file:///test/archive-reference.ini" + workspace_ref = open_doc( + workspace_ref_uri, + "Object ArchiveUser\n CommandSet = ArchivedSet\nEnd\n", + ) + assert "unresolved-reference" not in [ + diagnostic.get("code") for diagnostic in workspace_ref["diagnostics"] + ], workspace_ref["diagnostics"] + send({"jsonrpc": "2.0", "id": 35, "method": "textDocument/definition", + "params": {"textDocument": {"uri": workspace_ref_uri}, + "position": {"line": 1, "character": 20}}}) + archived_definition = wait_for( + lambda m: m.get("id") == 35 and "result" in m, + "workspace definition into BIG archive", + ) + assert archived_definition["result"] == [{ + "uri": archived_uri, + "range": { + "start": {"line": 3, "character": 11}, + "end": {"line": 3, "character": 22}, + }, + }], archived_definition["result"] + + parsed = urllib.parse.urlsplit(archived_uri) + vscode_path = urllib.parse.unquote(parsed.path) + if len(vscode_path) > 2 and vscode_path[2] == ":": + vscode_path = vscode_path[:1] + vscode_path[1].lower() + vscode_path[2:] + vscode_uri = "big:" + urllib.parse.quote(vscode_path, safe="/") + send({"jsonrpc": "2.0", "id": 36, "method": "zerosyntax/readVirtualFile", + "params": {"uri": vscode_uri}}) + virtual_file = wait_for( + lambda m: m.get("id") == 36 and "result" in m, + "VS Code-encoded virtual file", + ) + assert virtual_file["result"] == archived_text, virtual_file["result"] + + send({"jsonrpc": "2.0", "id": 37, "method": "zerosyntax/readVirtualFile", + "params": {"uri": archived_uri.replace("Archived.ini", "Unknown.ini")}}) + unknown_virtual = wait_for( + lambda m: m.get("id") == 37 and "result" in m, + "unknown virtual file", + ) + assert unknown_virtual["result"] is None + send({"jsonrpc": "2.0", "id": 38, "method": "zerosyntax/readVirtualFile", + "params": {"uri": "big:///C%3A/Game%FF/Base.big!/Data/INI/Archived.ini"}}) + malformed_virtual = wait_for( + lambda m: m.get("id") == 38 and "result" in m, + "malformed virtual file", + ) + assert malformed_virtual["result"] is None + + send({"jsonrpc": "2.0", "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": vscode_uri, "languageId": "generals-ini", + "version": 1, "text": archived_text}}}) + virtual_diag = wait_for( + lambda m: m.get("method") == "textDocument/publishDiagnostics" + and m["params"]["uri"] == archived_uri, + "virtual document diagnostics", + ) + assert virtual_diag + send({"jsonrpc": "2.0", "id": 39, "method": "textDocument/definition", + "params": {"textDocument": {"uri": vscode_uri}, + "position": {"line": 4, "character": 10}}}) + nested_definition = wait_for( + lambda m: m.get("id") == 39 and "result" in m, + "definition from inside BIG archive", + ) + assert nested_definition["result"] == [{ + "uri": archived_uri, + "range": { + "start": {"line": 0, "character": 14}, + "end": {"line": 0, "character": 28}, + }, + }], nested_definition["result"] + send({"jsonrpc": "2.0", "method": "textDocument/didClose", + "params": {"textDocument": {"uri": vscode_uri}}}) + print("OK: BIG definitions open through encoded read-only URIs and navigate") model_uri = "file:///test/hot-model.ini" model_text = ("Object HotModelObject\n" diff --git a/docs/language-server.md b/docs/language-server.md index ed15c88..a3dec57 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -120,7 +120,10 @@ symbols. INI spelling `stem.tga`. Audio and texture warnings activate independently only after that asset kind is indexed. Supply every loaded game/mod root to avoid warnings caused by a partial asset index. INI definitions are treated - as loaded before `map.ini` and `solo.ini`. + as loaded before `map.ini` and `solo.ini`. Definitions in loose files open + through native `file:` URIs. Definitions inside `.big` archives open as + read-only virtual INIs with navigation and inspection support when the client + selects the `big` URI scheme. The same settings can be sent at runtime through `workspace/didChangeConfiguration`, either directly or nested under diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index c6d0366..2469273 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -28,7 +28,10 @@ export function activate(context: vscode.ExtensionContext) { }; const clientOptions: LanguageClientOptions = { - documentSelector: [{ scheme: "file", language: "generals-ini" }], + documentSelector: [ + { scheme: "file", language: "generals-ini" }, + { scheme: "big", language: "generals-ini" }, + ], synchronize: { // Re-index when any .ini in the workspace changes on disk. fileEvents: vscode.workspace.createFileSystemWatcher("**/*.ini"), diff --git a/editors/vscode/src/test/runTest.ts b/editors/vscode/src/test/runTest.ts index d33eff6..a1e3a04 100644 --- a/editors/vscode/src/test/runTest.ts +++ b/editors/vscode/src/test/runTest.ts @@ -3,16 +3,49 @@ import * as os from "os"; import * as path from "path"; import { runTests } from "@vscode/test-electron"; +function writeBig(file: string, entries: Record) { + const names = Object.keys(entries); + let offset = 0x10 + names.reduce((size, name) => size + 8 + Buffer.byteLength(name) + 1, 0); + const header = Buffer.alloc(offset); + header.write("BIGF"); + header.writeUInt32BE(offset + names.reduce((size, name) => size + entries[name].length, 0), 4); + header.writeUInt32BE(names.length, 8); + let cursor = 0x10; + for (const name of names) { + header.writeUInt32BE(offset, cursor); + header.writeUInt32BE(entries[name].length, cursor + 4); + cursor += 8; + cursor += header.write(name, cursor); + header[cursor++] = 0; + offset += entries[name].length; + } + fs.writeFileSync(file, Buffer.concat([header, ...names.map((name) => entries[name])])); +} + async function main() { const extensionDevelopmentPath = path.resolve(__dirname, "../../.."); const extensionTestsPath = path.resolve(__dirname, "suite/index"); const testWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "zerosyntax-vscode-")); const testWorkspaceFile = path.join(testWorkspace, "ZeroSyntax.code-workspace"); + const archive = path.join(testWorkspace, "Base Cache #.big"); + writeBig(archive, { + "Data/INI/Archived.ini": Buffer.from( + "CommandButton SmokeArchivedButton\n" + + " Command = UNIT_BUILD\n" + + "End\n" + + "CommandSet SmokeArchivedSet\n" + + " 1 = SmokeArchivedButton\n" + + "End\n" + ), + }); fs.writeFileSync( testWorkspaceFile, JSON.stringify({ folders: [{ path: "." }], - settings: { "zerosyntax.analysis.allowPercentagesWithoutSign": false }, + settings: { + "zerosyntax.analysis.allowPercentagesWithoutSign": false, + "zerosyntax.baseIniRoots": [archive], + }, }) ); diff --git a/editors/vscode/src/test/suite/smoke.test.ts b/editors/vscode/src/test/suite/smoke.test.ts index 69c83bd..9a61d92 100644 --- a/editors/vscode/src/test/suite/smoke.test.ts +++ b/editors/vscode/src/test/suite/smoke.test.ts @@ -23,7 +23,8 @@ suite("ZeroSyntax VS Code extension", () => { uri, Buffer.from( "Weapon SmokeGun\n ScaleWeaponSpeed = Maybe\n \nEnd\n" + - "Armor SmokeArmor\n Armor = ARMOR_PIERCING 2\nEnd\n" + "Armor SmokeArmor\n Armor = ARMOR_PIERCING 2\nEnd\n" + + "Object SmokeObject\n CommandSet = SmokeArchivedSet\nEnd\n" ) ); @@ -80,6 +81,48 @@ suite("ZeroSyntax VS Code extension", () => { (items) => items.every((diag) => diag.code !== "bad-percent"), "hot-reloaded bare-percentage setting" ); + + const archivedLocations = await waitForAsync( + () => + vscode.commands.executeCommand( + "vscode.executeDefinitionProvider", + uri, + new vscode.Position(8, 20) + ), + (locations) => locations.some((location) => location.uri.scheme === "big"), + "definition into BIG archive" + ); + const archivedLocation = archivedLocations.find( + (location) => location.uri.scheme === "big" + ); + assert.ok(archivedLocation, "expected a BIG archive definition"); + + const archivedDocument = await vscode.workspace.openTextDocument(archivedLocation.uri); + await vscode.window.showTextDocument(archivedDocument); + assert.ok( + archivedDocument.getText().includes("CommandSet SmokeArchivedSet"), + `expected archived source text for ${archivedDocument.uri.toString()}, got ${JSON.stringify( + archivedDocument.getText() + )}` + ); + assert.strictEqual(archivedDocument.languageId, "generals-ini"); + + const nestedLocations = await waitForAsync( + () => + vscode.commands.executeCommand( + "vscode.executeDefinitionProvider", + archivedDocument.uri, + new vscode.Position(4, 12) + ), + (locations) => + locations.some( + (location) => + location.uri.toString() === archivedDocument.uri.toString() && + location.range.start.line === 0 + ), + "definition inside BIG archive" + ); + assert.ok(nestedLocations.length > 0); }); }); @@ -99,3 +142,20 @@ async function waitFor( } assert.fail(`timed out waiting for ${label}`); } + +async function waitForAsync( + get: () => Thenable, + done: (value: T) => boolean, + label: string +): Promise { + const deadline = Date.now() + 15000; + let value = await get(); + while (Date.now() < deadline) { + if (done(value)) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + value = await get(); + } + assert.fail(`timed out waiting for ${label}`); +}