Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 60 additions & 5 deletions crates/server/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -220,14 +221,30 @@ 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() {
if let Ok(canonical) = Url::from_file_path(path) {
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
}
Expand Down Expand Up @@ -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<Rope> {
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" {
Expand All @@ -840,9 +858,12 @@ impl Backend {
}

pub async fn read_virtual_file(&self, params: VirtualFileParams) -> Result<Option<String>> {
let Some(uri) = Url::parse(&params.uri).ok().map(canonical_uri) else {
return Ok(None);
};
Ok(self
.virtual_files
.get(&params.uri)
.get(uri.as_str())
.map(|text| text.to_string()))
}

Expand Down Expand Up @@ -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::<str>::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"));
Expand All @@ -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""),
Expand Down
136 changes: 131 additions & 5 deletions crates/server/tests/e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import threading
import queue
import struct
import urllib.parse


def frame(obj: dict) -> bytes:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion docs/language-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion editors/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
35 changes: 34 additions & 1 deletion editors/vscode/src/test/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Buffer>) {
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],
},
})
);

Expand Down
Loading