From 46c346d00e23127a3cae975b07a073669b3a1f52 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Thu, 7 May 2026 09:03:02 -0700 Subject: [PATCH 1/9] [WIP] Publishing and editing object scripts directly from the viewer. --- doc/Message_Interfaces.md | 402 +++++++++++++++++++++++++++++++++++++- package.json | 12 ++ src/extension.ts | 93 +++++++++ src/synchservice.ts | 87 ++++++++- src/utils.ts | 11 ++ src/viewereditwsclient.ts | 61 ++++++ 6 files changed, 661 insertions(+), 5 deletions(-) diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 52cb53f..45b725d 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -5,6 +5,7 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents - [Usage Flow](#usage-flow) +- [VS Code Launch URI](#vs-code-launch-uri) - [JSON-RPC Method Summary](#json-rpc-method-summary) - [Session Management Interfaces](#session-management-interfaces) - [SessionHandshake](#sessionhandshake) @@ -31,6 +32,17 @@ This document describes all the message interfaces defined for WebSocket communi - [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - [WebSocketHandlers](#websockethandlers) - [ClientInfo](#clientinfo) +- [Object Content Interfaces](#object-content-interfaces) + - [Core Data Types](#core-data-types) + - [ObjectPublish](#objectpublish) + - [ObjectUnpublish](#objectunpublish) + - [ObjectUpdate](#objectupdate) + - [ObjectContentGet](#objectcontentget) + - [ObjectContentSave](#objectcontentsave) + - [ObjectItemCreate](#objectitemcreate) + - [ObjectItemDelete](#objectitemdelete) + - [ObjectScriptSetRunning](#objectscriptsetrunning) + - [ObjectRequest](#objectrequest) ## Usage Flow @@ -53,17 +65,84 @@ This document describes all the message interfaces defined for WebSocket communi - When subscription needs to be terminated, viewer sends `script.unsubscribe` notification with `ScriptUnsubscribe` data - Extension handles unsubscription by cleaning up local script tracking -4. **Runtime Events:** +4. **Object Content Publishing:** + + - Viewer sends `object.publish` notification when an in-world object's contents are made available for editing + - Viewer sends `object.unpublish` notification when an object is removed or the owner stops publishing + - Viewer sends `object.update` notification when object inventory changes (full replacement or delta) + - Extension calls `object.content.get` to fetch an item's content on demand + - Extension calls `object.content.save` to write modified content back to the viewer + - Extension calls `object.item.create` / `object.item.delete` to manage inventory items + - Extension calls `object.script.set_running` to start or stop a script + +5. **Runtime Events:** - Viewer sends `language.syntax.change` notification with `SyntaxChange` when language changes - Viewer sends `script.compiled` notification with `CompilationResult` after script compilation - Viewer sends `runtime.debug` notification with `RuntimeDebug` for debug messages during script execution - Viewer sends `runtime.error` notification with `RuntimeError` when runtime errors occur -5. **Connection Termination:** +6. **Connection Termination:** - Either side can send `session.disconnect` notification with `SessionDisconnect` data - Connection is closed gracefully +## VS Code Launch URI + +The viewer can launch VS Code and trigger an automatic WebSocket connection by opening a `vscode://` URI via the operating system's default URI handler. The extension registers a URI handler for this scheme; VS Code will launch itself if not already running and deliver the URI to the extension. + +### URI Format + +``` +vscode://lindenlab.sl-vscode-plugin/connect[?port=][&object=][&script=] +``` + +### Parameters + +| Parameter | Required | Description | +| --------- | -------- | ----------- | +| `port` | No | Port number the viewer's WebSocket server is listening on. Overrides the user's configured port for this session. Defaults to the configured `slVscodeEdit.network.websocketPort` (default `9020`) if absent. Must be in range 1024–65535. | +| `object` | No | UUID of a root prim. After the handshake completes the extension calls `object.request` to ask the viewer to publish this object. The viewer then sends an `object.publish` notification and the object appears as a workspace folder in the Explorer. | +| `script` | No | UUID of a script. After the handshake completes the extension locates the corresponding temp file via `script.list` and opens it, triggering the normal `script.subscribe` + live-sync flow. | + +`object` and `script` are mutually exclusive in typical use but both may be supplied; the extension will process both. + +### Examples + +``` +# Open VS Code and connect on default port +vscode://lindenlab.sl-vscode-plugin/connect + +# Connect on a custom port +vscode://lindenlab.sl-vscode-plugin/connect?port=9021 + +# Connect and immediately publish a specific object +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&object=550e8400-e29b-41d4-a716-446655440000 + +# Connect and open a specific script for editing +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&script=6ba7b810-9dad-11d1-80b4-00c04fd430c8 +``` + +### Post-connection sequence + +When the URI contains an `object` or `script` parameter the extension acts only **after** the handshake is fully complete (`session.ok` received): + +``` +URI received by extension + │ + ▼ +WebSocket connects → session.handshake → session.ok + │ + ├─ object= → object.request({ object_id }) call + │ │ + │ ▼ (async, when viewer is ready) + │ object.publish notification + │ + └─ script= → script.list call → open temp file + │ + ▼ + script.subscribe + live-sync +``` + ## JSON-RPC Method Summary | Method | Direction | Type | Interface/Parameters | @@ -89,6 +168,21 @@ This document describes all the message interfaces defined for WebSocket communi | `script.compiled` | Viewer → Extension | Notification | `CompilationResult` | | `runtime.debug` | Viewer → Extension | Notification | `RuntimeDebug` | | `runtime.error` | Viewer → Extension | Notification | `RuntimeError` | +| `object.publish` | Viewer → Extension | Notification | `ObjectPublishMessage` | +| `object.unpublish` | Viewer → Extension | Notification | `ObjectUnpublishMessage` | +| `object.update` | Viewer → Extension | Notification | `ObjectUpdateMessage` | +| `object.content.get` | Extension → Viewer | Call | `ObjectContentGetParams` | +| `object.content.get` (response) | Viewer → Extension | Response | `ObjectContentGetResponse` | +| `object.content.save` | Extension → Viewer | Call | `ObjectContentSaveParams` | +| `object.content.save` (response)| Viewer → Extension | Response | `ObjectContentSaveResponse`| +| `object.item.create` | Extension → Viewer | Call | `ObjectItemCreateParams` | +| `object.item.create` (response) | Viewer → Extension | Response | `ObjectItemCreateResponse` | +| `object.item.delete` | Extension → Viewer | Call | `ObjectItemDeleteParams` | +| `object.item.delete` (response) | Viewer → Extension | Response | `ObjectItemDeleteResponse` | +| `object.script.set_running` | Extension → Viewer | Call | `ObjectScriptSetRunningParams` | +| `object.script.set_running` (response) | Viewer → Extension | Response | `ObjectScriptSetRunningResponse` | +| `object.request` | Extension → Viewer | Call | `ObjectRequestParams` | +| `object.request` (response) | Viewer → Extension | Response | `ObjectRequestResponse` | ## Session Management Interfaces @@ -577,3 +671,307 @@ interface ClientInfo { - `scriptName`: Name of the script being edited - `scriptId`: Unique identifier for the script - `extension`: File extension or script type + +--- + +## Object Content Interfaces + +These interfaces support publishing in-world object inventories (scripts and notecards) to the external editor as a browseable virtual filesystem. The extension exposes published objects under the `sl://objects/` URI scheme. + +### Core Data Types + +```typescript +type InventoryItemType = "script" | "notecard"; + +type ScriptVM = "lso" | "mono" | "luau"; + +/** Permission mask fields. Only owner and next_owner are transmitted. */ +interface ItemPermissions { + owner: number; // e.g. PERM_MODIFY=0x4000, PERM_COPY=0x8000, PERM_TRANSFER=0x2000 + next_owner: number; +} + +/** + * Inventory item within an object or linked prim. + * asset_id is intentionally never transmitted. + */ +interface ObjectInventoryItem { + item_id: string; // Inventory item UUID + name: string; // Display name (no file extension) + description?: string; + type: InventoryItemType; + subtype?: number; // Scripts only: language from II_FLAGS_SUBTYPE_MASK (0=LSL, 1=Luau) + vm?: ScriptVM; // Scripts only: which VM the script targets + running?: boolean; // Scripts only: whether the script is running + permissions?: ItemPermissions; + creator_id?: string; +} + +/** A linked (child) prim within a linkset */ +interface LinkedObject { + link_id: string; // UUID of the linked prim + link_number: number; // Link number (root=1, children≥2) + link_name: string; + link_description?: string; + inventory: ObjectInventoryItem[]; +} + +interface ObjectPermissions { + owner: number; + next_owner: number; +} + +/** Root of a linkset, as published to the extension */ +interface PublishedObject { + object_id: string; // UUID of the root prim + object_name: string; + object_description?: string; + region?: string; + owner_id?: string; + permissions?: ObjectPermissions; + inventory: ObjectInventoryItem[]; // Root prim's scripts and notecards + linked_objects?: LinkedObject[]; // Child prims +} +``` + +**Script display extensions** (synthetic, derived from `subtype`): + +| `subtype` | Extension | +| --------- | --------- | +| `0` (LSL) | `.lsl` | +| `1` (Luau)| `.luau` | +| notecard | `.txt` | + +--- + +### ObjectPublish + +**JSON-RPC Method:** `object.publish` (notification from viewer) + +Sent when the viewer publishes an in-world object's inventory for external editing. Triggers creation of a virtual filesystem workspace folder in the extension. + +```typescript +interface ObjectPublishMessage { + object: PublishedObject; +} +``` + +**Fields:** + +- `object`: The full published object tree, including root prim inventory and all linked prim inventories. + +--- + +### ObjectUnpublish + +**JSON-RPC Method:** `object.unpublish` (notification from viewer) + +Sent when the viewer removes a previously published object — for example when the owner deselects it, moves away, or the object is deleted. + +```typescript +interface ObjectUnpublishMessage { + object_id: string; + reason?: string; +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim that is being unpublished +- `reason` (optional): Human-readable explanation (e.g. `"object deleted"`, `"out of range"`) + +--- + +### ObjectUpdate + +**JSON-RPC Method:** `object.update` (notification from viewer) + +Sent when the inventory of a published object changes. Supports two modes: +- **Full replacement**: `inventory` and/or `linked_objects` fields replace the entire prior state. +- **Delta update**: `changes` field describes only what changed. Takes precedence over full replacement fields when present. + +```typescript +interface InventoryChanges { + added?: ObjectInventoryItem[]; + removed?: string[]; // item_ids removed + modified?: ObjectInventoryItem[]; // metadata-only changes + content_changed?: string[]; // item_ids whose content changed (invalidates cache) + running_changed?: { item_id: string; running: boolean }[]; // running state toggled +} + +interface LinkedObjectChanges { + added?: LinkedObject[]; + removed?: string[]; // link_ids removed + modified?: { + link_id: string; + link_name?: string; + inventory?: InventoryChanges; + }[]; +} + +interface ObjectUpdateMessage { + object_id: string; + object_name?: string; + // Full replacement (used when changes is absent) + inventory?: ObjectInventoryItem[]; + linked_objects?: LinkedObject[]; + // Delta (takes precedence when present) + changes?: { + inventory?: InventoryChanges; + linked_objects?: LinkedObjectChanges; + }; +} +``` + +--- + +### ObjectContentGet + +**JSON-RPC Method:** `object.content.get` (call from extension to viewer) + +Requests the text content of a script or notecard. The extension calls this lazily when the user opens a file in the virtual filesystem. + +```typescript +interface ObjectContentGetParams { + prim_id: string; // UUID of any prim (root or child) — no object_id + link_id needed + item_id: string; +} + +interface ObjectContentGetResponse { + prim_id: string; + item_id: string; + content: string; + encoding?: "utf-8" | "base64"; +} +``` + +**Fields:** + +- `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID without knowing the root object_id. +- `item_id`: Inventory item UUID. +- `content`: The raw text content of the item. +- `encoding` (optional): Encoding used for `content`. Defaults to `"utf-8"` if absent. + +--- + +### ObjectContentSave + +**JSON-RPC Method:** `object.content.save` (call from extension to viewer) + +Writes modified content back to the viewer. For scripts, the viewer will attempt to compile the updated source. + +```typescript +interface ObjectContentSaveParams { + prim_id: string; + item_id: string; + content: string; +} + +interface ObjectContentSaveResponse { + success: boolean; + message?: string; +} +``` + +**Fields:** + +- `success`: Whether the save (and compilation, if applicable) succeeded. +- `message` (optional): Error description on failure. + +--- + +### ObjectItemCreate + +**JSON-RPC Method:** `object.item.create` (call from extension to viewer) + +Creates a new script or notecard in a prim's inventory. + +```typescript +interface ObjectItemCreateParams { + prim_id: string; + name: string; // Pure SL inventory name — no file extension + type: InventoryItemType; // "script" | "notecard" + vm?: ScriptVM; // Scripts only: target VM. Defaults to viewer default if absent. + content?: string; // Initial content. Empty item created if absent. +} + +interface ObjectItemCreateResponse { + success: boolean; + item_id?: string; // UUID of the created item (on success) + item?: ObjectInventoryItem; // Full item metadata including assigned subtype (on success) + message?: string; // Error description (on failure) +} +``` + +--- + +### ObjectItemDelete + +**JSON-RPC Method:** `object.item.delete` (call from extension to viewer) + +Deletes a script or notecard from a prim's inventory. Requires `PERM_MODIFY` on the item. + +```typescript +interface ObjectItemDeleteParams { + prim_id: string; + item_id: string; +} + +interface ObjectItemDeleteResponse { + success: boolean; + message?: string; +} +``` + +--- + +### ObjectScriptSetRunning + +**JSON-RPC Method:** `object.script.set_running` (call from extension to viewer) + +Starts or stops a script within a prim. + +```typescript +interface ObjectScriptSetRunningParams { + prim_id: string; + item_id: string; + running: boolean; // true = start, false = stop +} + +interface ObjectScriptSetRunningResponse { + success: boolean; + message?: string; +} +``` + +--- + +### ObjectRequest + +**JSON-RPC Method:** `object.request` (call from extension to viewer) + +Requests the viewer to publish a specific in-world object. The viewer responds synchronously to confirm the request was accepted, then asynchronously sends an `object.publish` notification with the full object tree. + +This is typically called immediately after the handshake completes when the extension was launched by the viewer with an `object=` URI parameter. + +```typescript +interface ObjectRequestParams { + object_id: string; // UUID of the root prim to request publishing for +} + +interface ObjectRequestResponse { + success: boolean; + message?: string; // reason on failure (e.g. "object not found", "permission denied") +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim of the linkset to publish. +- `success`: Whether the viewer accepted the request. A `true` response does not mean `object.publish` has been sent yet — it means the viewer will send it. +- `message` (optional): Human-readable failure reason. Only present when `success` is `false`. + +**Sequence:** +1. Extension calls `object.request` +2. Viewer responds with `{ success: true }` (or error) +3. Viewer sends `object.publish` notification (asynchronously, when ready) diff --git a/package.json b/package.json index 06c90ad..c3a2f57 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,18 @@ "url": "https://github.com/secondlife/sl-vscode-plugin.git" }, "contributes": { + "resourceLabelFormatters": [ + { + "scheme": "sl", + "authority": "objects", + "formatting": { + "label": "${path}", + "separator": "/", + "tildify": false, + "workspaceSuffix": "Second Life" + } + } + ], "commands": [ { "command": "second-life-scripting.enable", diff --git a/src/extension.ts b/src/extension.ts index 4ad5f35..e5c92ce 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,12 +5,16 @@ import * as vscode from "vscode"; import { SynchService } from "./synchservice"; import { LanguageService } from "./shared/languageservice"; +import { ObjectContentService } from "./vscode/objectcontentservice"; +import { ObjectContentProvider, SL_SCHEME, SL_AUTHORITY, rootUri } from "./vscode/objectcontentprovider"; +import { ObjectContentDecorator } from "./vscode/ObjectContentDecorator"; import { ConfigService, configPrefix } from "./configservice"; import { VSCodeHost, getOutputChannel, showOutputChannel, logInfo, + logDebug, showStatusMessage, hasWorkspace, showErrorMessage @@ -28,6 +32,95 @@ export function activate(context: vscode.ExtensionContext): void { // Initialize the file sync functionality const synchService = SynchService.getInstance(context); + // Initialize object content service and register the sl:// FileSystemProvider + const objectContentService = ObjectContentService.getInstance(); + const objectContentProvider = new ObjectContentProvider( + objectContentService, + () => synchService.getWebSocket(), + ); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(SL_SCHEME, objectContentProvider, { + isCaseSensitive: true, + }), + objectContentProvider, + objectContentService, + ); + + // Register file decoration provider for sl:// URIs (shows disconnected state) + const objectContentDecorator = new ObjectContentDecorator( + () => synchService.isConnected(), + (listener) => synchService.onDidChangeConnectionState(listener), + ); + context.subscriptions.push( + vscode.window.registerFileDecorationProvider(objectContentDecorator), + objectContentDecorator, + ); + + // Manage workspace folders for published objects so Explorer shows friendly names + context.subscriptions.push( + objectContentService.onDidChangeObjects(({ type, object_id }) => { + const folders = vscode.workspace.workspaceFolders ?? []; + const slIdx = folders.findIndex( + (f) => f.uri.scheme === SL_SCHEME && f.uri.authority === SL_AUTHORITY && f.uri.path === `/${object_id}` + ); + if (type === "added") { + const entry = objectContentService.getObject(object_id); + if (entry && slIdx === -1) { + vscode.workspace.updateWorkspaceFolders(folders.length, 0, { + uri: rootUri(object_id), + name: entry.object.object_name, + }); + } + } else if (type === "removed") { + if (slIdx !== -1) { + vscode.workspace.updateWorkspaceFolders(slIdx, 1); + } + } + }) + ); + + // Register URI handler so the viewer can launch VS Code and trigger a connection. + // URI format: vscode://lindenlab.sl-vscode-plugin/connect?port=9020[&object=][&script=] + context.subscriptions.push( + vscode.window.registerUriHandler({ + handleUri(uri: vscode.Uri): void { + + logDebug(`Received URI: ${uri.toString()}`); + + if (uri.path !== "/connect") { return; } + + // Decode the query string first - some viewers incorrectly encode the delimiters + const decodedQuery = decodeURIComponent(uri.query); + logDebug(`Decoded query: ${decodedQuery}`); + + const query = Object.fromEntries( + decodedQuery.split("&").filter(Boolean).map(p => { + const eq = p.indexOf("="); + return eq === -1 + ? [p, ""] + : [p.slice(0, eq), p.slice(eq + 1)]; + }) + ); + + logDebug(`Parsed query: ${JSON.stringify(query)}`); + + const rawPort = query["port"] as string | undefined; + const port = rawPort !== undefined ? parseInt(rawPort, 10) : undefined; + if (port !== undefined && (isNaN(port) || port < 1024 || port > 65535)) { + showErrorMessage(`Second Life: Invalid port in launch URI: ${rawPort}`); + return; + } + + const object_id = query["object"] as string | undefined; + const script_id = query["script"] as string | undefined; + + logInfo(`Connecting with port=${port}, object_id=${object_id ?? "(none)"}, script_id=${script_id ?? "(none)"}`); + + synchService.connectToViewer({ port, object_id, script_id }); + } + }) + ); + // Register output channel for disposal context.subscriptions.push(getOutputChannel()); diff --git a/src/synchservice.ts b/src/synchservice.ts index 6a89be3..4611466 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -20,6 +20,11 @@ import { RuntimeDebug, RuntimeError, } from "./viewereditwsclient"; +import { + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, +} from "./vscode/objectcontentinterfaces"; import { hasWorkspace, showInfoMessage, @@ -35,6 +40,7 @@ import { ScriptSync } from "./scriptsync"; import { getLanguageConfig } from "./shared/lexer"; import { HostInterface } from "./interfaces/hostinterface"; import { SyncedFileDecorator } from "./vscode/SyncedFileDecorator"; +import { ObjectContentService } from "./vscode/objectcontentservice"; type ParsedTempFile = { scriptName: string; scriptId: string; extension: string, language: ScriptLanguage }; @@ -50,6 +56,8 @@ export class SynchService implements vscode.Disposable { private activeSync: ScriptSync | undefined; private host: HostInterface; private initialGenerationDone: boolean = false; + private pendingLaunchObjectId?: string; + private pendingLaunchScriptId?: string; public viewerName?: string; public viewerVersion?: string; @@ -62,12 +70,17 @@ export class SynchService implements vscode.Disposable { private syncedFileDecorator : SyncedFileDecorator; + private _onDidChangeConnectionState = new vscode.EventEmitter(); + readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; + private disposables: vscode.Disposable[] = []; private constructor(context: vscode.ExtensionContext) { this.context = context; this.host = new VSCodeHost(); this.syncedFileDecorator = new SyncedFileDecorator(this); + // Note: _onDidChangeConnectionState is NOT added to disposables + // because it must survive activate/deactivate cycles } public static getInstance(context?: vscode.ExtensionContext): SynchService { @@ -337,7 +350,7 @@ export class SynchService implements vscode.Disposable { //==================================================================== //#region WebSocket connection management and handlers - private async setupConnection(): Promise { + private async setupConnection(portOverride?: number): Promise { const handlers = { onHandshake: (message: SessionHandshake): any => this.onHandshake(message), onHandshakeOk: (): any => this.onHandshakeOk(), @@ -348,7 +361,9 @@ export class SynchService implements vscode.Disposable { onCompilationResult: (message: CompilationResult): any => this.onCompilationResult(message), onRuntimeDebug: (message: RuntimeDebug): any => this.onRuntimeDebug(message), onRuntimeError: (message: RuntimeError): any => this.onRuntimeError(message), - + onObjectPublish: (msg: ObjectPublishMessage): any => ObjectContentService.getInstance().handlePublish(msg), + onObjectUnpublish: (msg: ObjectUnpublishMessage): any => ObjectContentService.getInstance().handleUnpublish(msg), + onObjectUpdate: (msg: ObjectUpdateMessage): any => ObjectContentService.getInstance().handleUpdate(msg), }; if (this.websocket && this.websocket.isConnected()) { @@ -359,9 +374,11 @@ export class SynchService implements vscode.Disposable { this.getHandshakePromise(); showStatusMessage("Connecting to Second Life viewer...", handshake); + const port = portOverride + ?? this.host.config.getConfig(ConfigKey.NetworkWebsocketPort, 9020); this.websocket = new ViewerEditWSClient( this.context, - `ws://localhost:${this.host.config.getConfig(ConfigKey.NetworkWebsocketPort, 9020)}` + `ws://localhost:${port}` ); this.websocket.setup(handlers); let connected = await this.websocket.connect(); @@ -424,6 +441,7 @@ export class SynchService implements vscode.Disposable { error_reporting: true, debugging: false, breakpoints: false, + object_publish: true, }, }; return response; @@ -456,6 +474,10 @@ export class SynchService implements vscode.Disposable { if (this.handshakeResolve) { this.handshakeResolve(true, "Connected"); } + + this._onDidChangeConnectionState.fire(true); + + await this.handleLaunchParams(); } private onDisconnect(params: SessionDisconnect): void { @@ -472,8 +494,11 @@ export class SynchService implements vscode.Disposable { ); } + this._onDidChangeConnectionState.fire(false); + // Don't dispose immediately - let the connection close handler do the cleanup // The websocket will be closed by the server, triggering our close handler + ObjectContentService.getInstance().clear(); } private onScriptUnsubscribe(message: ScriptUnsubscribe): void { @@ -872,6 +897,10 @@ export class SynchService implements vscode.Disposable { public getWebSocket(): ViewerEditWSClient | undefined { return this.websocket; } + + public isConnected(): boolean { + return this.websocket?.isConnected() ?? false; + } //#endregion //==================================================================== @@ -954,6 +983,58 @@ export class SynchService implements vscode.Disposable { } //#endregion + public async connectToViewer(params: { port?: number; object_id?: string; script_id?: string }): Promise { + this.pendingLaunchObjectId = params.object_id; + this.pendingLaunchScriptId = params.script_id; + + if (this.websocket?.isConnected()) { + // Already connected — act on params immediately + await this.handleLaunchParams(); + return; + } + + await this.setupConnection(params.port); + // handleLaunchParams is called from onHandshakeOk + } + + private async handleLaunchParams(): Promise { + const objectId = this.pendingLaunchObjectId; + const scriptId = this.pendingLaunchScriptId; + this.pendingLaunchObjectId = undefined; + this.pendingLaunchScriptId = undefined; + + if (objectId && this.websocket?.isConnected()) { + const result = await this.websocket.requestObject({ object_id: objectId }); + if (!result.success) { + showWarningMessage(`Failed to request object: ${result.message ?? "unknown error"}`); + } + } + + if (scriptId && this.websocket?.isConnected()) { + await this.openScriptById(scriptId); + } + } + + private async openScriptById(scriptId: string): Promise { + if (!this.websocket) { return; } + const list = await this.websocket.getScriptList(); + if (!list.success) { return; } + + try { + const files = await fs.promises.readdir(list.temp_dir); + const match = files.find(f => f.includes(scriptId)); + if (match) { + const tempPath = path.join(list.temp_dir, match); + await vscode.window.showTextDocument(vscode.Uri.file(tempPath)); + // onOpenTextDocument fires and handles the normal subscribe + sync flow + } else { + showWarningMessage(`Script ${scriptId} not found in viewer temp directory`); + } + } catch { + showWarningMessage(`Could not open script ${scriptId} from temp directory`); + } + } + public activate(): void { this.deactivate(); this.initialize(); diff --git a/src/utils.ts b/src/utils.ts index bea5205..e346133 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -36,6 +36,17 @@ export function logInfo(message: string): void { channel.appendLine(`[${timestamp}] INFO: ${message}`); } +/** + * Log a debug message to the output channel (only when debug logging is enabled) + */ +export function logDebug(message: string): void { + // TODO: Check a debug setting to conditionally log + // For now, always log debug messages + const channel = getOutputChannel(); + const timestamp = new Date().toISOString(); + channel.appendLine(`[${timestamp}] DEBUG: ${message}`); +} + /** * Log a warning message to the output channel */ diff --git a/src/viewereditwsclient.ts b/src/viewereditwsclient.ts index 90722b2..9748d2c 100644 --- a/src/viewereditwsclient.ts +++ b/src/viewereditwsclient.ts @@ -7,6 +7,23 @@ import { JSONRPCClient } from "./websockclient"; import { ConfigService } from "./configservice"; import { ConfigKey } from "./interfaces/configinterface"; import { showStatusMessage } from "./utils"; +import { + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, + ObjectContentGetParams, + ObjectContentGetResponse, + ObjectContentSaveParams, + ObjectContentSaveResponse, + ObjectItemCreateParams, + ObjectItemCreateResponse, + ObjectItemDeleteParams, + ObjectItemDeleteResponse, + ObjectScriptSetRunningParams, + ObjectScriptSetRunningResponse, + ObjectRequestParams, + ObjectRequestResponse, +} from "./vscode/objectcontentinterfaces"; //#region Message Formats @@ -67,6 +84,12 @@ export interface SyntaxCacheList { success: boolean; } +export interface ScriptList { + temp_dir: string; + script_ids: string[]; + success: boolean; +} + export interface SyntaxCacheGetRequest { filename: string; as_json?: boolean; @@ -124,6 +147,9 @@ export interface WebSocketHandlers { onRuntimeDebug?: (message: RuntimeDebug) => void; onRuntimeError?: (message: RuntimeError) => void; onConnectionClosed?: () => void; + onObjectPublish?: (message: ObjectPublishMessage) => void; + onObjectUnpublish?: (message: ObjectUnpublishMessage) => void; + onObjectUpdate?: (message: ObjectUpdateMessage) => void; } /** @@ -186,6 +212,9 @@ export class ViewerEditWSClient extends JSONRPCClient { this.on("script.compiled", this.handlers.onCompilationResult); this.on("runtime.debug", this.handlers.onRuntimeDebug); this.on("runtime.error", this.handlers.onRuntimeError); + this.on("object.publish", this.handlers.onObjectPublish); + this.on("object.unpublish", this.handlers.onObjectUnpublish); + this.on("object.update", this.handlers.onObjectUpdate); // Setup connection close handler this.setupConnectionCloseHandler(); @@ -226,6 +255,38 @@ export class ViewerEditWSClient extends JSONRPCClient { } } + // ============================================ + // Object Content Calls (Extension → Viewer) + // ============================================ + + public getObjectContent(params: ObjectContentGetParams): Promise { + return this.call("object.content.get", params); + } + + public saveObjectContent(params: ObjectContentSaveParams): Promise { + return this.call("object.content.save", params); + } + + public createObjectItem(params: ObjectItemCreateParams): Promise { + return this.call("object.item.create", params); + } + + public deleteObjectItem(params: ObjectItemDeleteParams): Promise { + return this.call("object.item.delete", params); + } + + public setScriptRunning(params: ObjectScriptSetRunningParams): Promise { + return this.call("object.script.set_running", params); + } + + public requestObject(params: ObjectRequestParams): Promise { + return this.call("object.request", params); + } + + public getScriptList(): Promise { + return this.call("script.list", {}); + } + private setupConnectionCloseHandler(): void { // Instead of overriding dispose, use a periodic check for connection state const checkConnectionInterval = setInterval(() => { From 808125a5cd258632291b1b1d21c64256671c504f Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Tue, 19 May 2026 08:57:10 -0700 Subject: [PATCH 2/9] [WIP] Act as a consumer for objects published from the viewer. Treat them as virtual file systems, so that they appear as folders in the explorer tab. Not yet in a stable state. --- doc/Message_Interfaces.md | 144 ++++--- src/synchservice.ts | 24 +- src/vscode/ObjectContentDecorator.ts | 86 ++++ src/vscode/objectcontentinterfaces.ts | 238 +++++++++++ src/vscode/objectcontentprovider.ts | 587 ++++++++++++++++++++++++++ src/vscode/objectcontentservice.ts | 332 +++++++++++++++ src/websockclient.ts | 38 +- 7 files changed, 1382 insertions(+), 67 deletions(-) create mode 100644 src/vscode/ObjectContentDecorator.ts create mode 100644 src/vscode/objectcontentinterfaces.ts create mode 100644 src/vscode/objectcontentprovider.ts create mode 100644 src/vscode/objectcontentservice.ts diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 45b725d..022378a 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -4,45 +4,45 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents -- [Usage Flow](#usage-flow) -- [VS Code Launch URI](#vs-code-launch-uri) -- [JSON-RPC Method Summary](#json-rpc-method-summary) -- [Session Management Interfaces](#session-management-interfaces) - - [SessionHandshake](#sessionhandshake) - - [SessionHandshakeResponse](#sessionhandshakeresponse) - - [Session OK](#session-ok) - - [SessionDisconnect](#sessiondisconnect) -- [Language and Syntax Interfaces](#language-and-syntax-interfaces) - - [SyntaxChange](#syntaxchange) - - [Language Syntax ID Request](#language-syntax-id-request) - - [Language Syntax Request](#language-syntax-request) - - [Language Syntax Cache List](#language-syntax-cache-list) - - [Language Syntax Cache Get](#language-syntax-cache-get) -- [Script Subscription Interfaces](#script-subscription-interfaces) - - [ScriptSubscribe](#scriptsubscribe) - - [ScriptSubscribeResponse](#scriptsubscriberesponse) - - [ScriptUnsubscribe](#scriptunsubscribe) - - [ScriptList](#scriptlist) -- [Compilation Interfaces](#compilation-interfaces) - - [CompilationError](#compilationerror) - - [CompilationResult](#compilationresult) -- [Runtime Event Interfaces](#runtime-event-interfaces) - - [RuntimeDebug](#runtimedebug) - - [RuntimeError](#runtimeerror) -- [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - - [WebSocketHandlers](#websockethandlers) - - [ClientInfo](#clientinfo) -- [Object Content Interfaces](#object-content-interfaces) - - [Core Data Types](#core-data-types) - - [ObjectPublish](#objectpublish) - - [ObjectUnpublish](#objectunpublish) - - [ObjectUpdate](#objectupdate) - - [ObjectContentGet](#objectcontentget) - - [ObjectContentSave](#objectcontentsave) - - [ObjectItemCreate](#objectitemcreate) - - [ObjectItemDelete](#objectitemdelete) - - [ObjectScriptSetRunning](#objectscriptsetrunning) - - [ObjectRequest](#objectrequest) +- [Usage Flow](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#usage-flow) +- [VS Code Launch URI](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#vs-code-launch-uri) +- [JSON-RPC Method Summary](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#json-rpc-method-summary) +- [Session Management Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-management-interfaces) + - [SessionHandshake](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshake) + - [SessionHandshakeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshakeresponse) + - [Session OK](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-ok) + - [SessionDisconnect](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessiondisconnect) +- [Language and Syntax Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-and-syntax-interfaces) + - [SyntaxChange](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#syntaxchange) + - [Language Syntax ID Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-id-request) + - [Language Syntax Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-request) + - [Language Syntax Cache List](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-list) + - [Language Syntax Cache Get](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-get) +- [Script Subscription Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#script-subscription-interfaces) + - [ScriptSubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscribe) + - [ScriptSubscribeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscriberesponse) + - [ScriptUnsubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptunsubscribe) + - [ScriptList](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptlist) +- [Compilation Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilation-interfaces) + - [CompilationError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationerror) + - [CompilationResult](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationresult) +- [Runtime Event Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtime-event-interfaces) + - [RuntimeDebug](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimedebug) + - [RuntimeError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimeerror) +- [Handler and Configuration Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#handler-and-configuration-interfaces) + - [WebSocketHandlers](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#websockethandlers) + - [ClientInfo](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#clientinfo) +- [Object Content Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#object-content-interfaces) + - [Core Data Types](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#core-data-types) + - [ObjectPublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectpublish) + - [ObjectUnpublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectunpublish) + - [ObjectUpdate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectupdate) + - [ObjectContentGet](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentget) + - [ObjectContentSave](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentsave) + - [ObjectItemCreate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemcreate) + - [ObjectItemDelete](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemdelete) + - [ObjectScriptSetRunning](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectscriptsetrunning) + - [ObjectRequest](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectrequest) ## Usage Flow @@ -378,14 +378,14 @@ interface SyntaxCacheList { | -------------------------------- | ---------------------------------------------------- | | `builtins.txt` | LSL built-in keyword list in plain text format | | `lsl_definitions.yaml` | LSL language definitions in YAML format | -| `lsl_keywords.xml` | LSL keyword definitions in LLSD XML format | +| `lsl_keywords.xml` | LSL keyword definitions in LLSD XML format. Used by the viewer's script editor | | `lsl_keywords_pretty.xml` | LSL keyword definitions in formatted LLSD XML format | -| `slua_default.d.luau` | Luau type definition file for editor tooling | -| `slua_default.docs.json` | Luau documentation data in JSON format | +| `secondlife.d.luau` | Luau type definition file. Used by luau-lsp | +| `secondlife.docs.json` | Luau documentation data in JSON format. Used by luau-lsp | | `slua_definitions.yaml` | Luau language definitions in YAML format | -| `slua_keywords.xml` | Luau keyword definitions in LLSD XML format | -| `slua_keywords_pretty.xml` | Luau keyword definitions in formatted LLSD XML format | -| `slua_selene.yml` | Luau Selene linter configuration in YAML format | +| `lua_keywords.xml` | Luau keyword definitions in LLSD XML format. Used by the viewer's script editor | +| `lua_keywords_pretty.xml` | Luau keyword definitions in formatted LLSD XML format | +| `secondlife_selene.yml` | Luau Selene linter configuration in YAML format | Not all files may be present in every cache — the actual list returned by `language.syntax.cache` reflects only what is available on the viewer's local filesystem at the time of the request. @@ -683,7 +683,7 @@ These interfaces support publishing in-world object inventories (scripts and not ```typescript type InventoryItemType = "script" | "notecard"; -type ScriptVM = "lso" | "mono" | "luau"; +type ScriptVM = "lsl2" | "mono" | "luau"; /** Permission mask fields. Only owner and next_owner are transmitted. */ interface ItemPermissions { @@ -838,10 +838,10 @@ interface ObjectContentGetParams { } interface ObjectContentGetResponse { + success: boolean; prim_id: string; item_id: string; - content: string; - encoding?: "utf-8" | "base64"; + content: string; // Raw text content (UTF-8). Notecard envelope is unwrapped automatically. } ``` @@ -849,8 +849,8 @@ interface ObjectContentGetResponse { - `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID without knowing the root object_id. - `item_id`: Inventory item UUID. -- `content`: The raw text content of the item. -- `encoding` (optional): Encoding used for `content`. Defaults to `"utf-8"` if absent. +- `success`: `true` on success. +- `content`: The raw text content of the item. For notecards, the `Linden text version 2` envelope is stripped — only the body text is returned. --- @@ -865,17 +865,28 @@ interface ObjectContentSaveParams { prim_id: string; item_id: string; content: string; + vm?: "mono" | "lsl2" | "luau"; } interface ObjectContentSaveResponse { success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; + errors?: string[]; message?: string; } ``` **Fields:** -- `success`: Whether the save (and compilation, if applicable) succeeded. +- `prim_id`: UUID of the prim that owns the saved item. +- `item_id`: UUID of the saved inventory item. +- `content`: Raw script/notecard source text to store. +- `vm` (optional): Scripts only compile target. Accepted values are `"mono"`, `"lsl2"`, `"luau"`. When `"luau"` is specified for an LSL script (as opposed to a native Luau script), the viewer automatically selects the correct LSL-on-Luau compile path. If omitted, inferred from item metadata or content analysis. +- `success`: Whether the upload/save operation succeeded. +- `compiled` (optional): Scripts only. `true` when compilation succeeded, `false` when source saved but compile failed. +- `errors` (optional): Scripts only. Compiler diagnostics when `compiled` is `false`. - `message` (optional): Error description on failure. --- @@ -884,25 +895,33 @@ interface ObjectContentSaveResponse { **JSON-RPC Method:** `object.item.create` (call from extension to viewer) -Creates a new script or notecard in a prim's inventory. +Creates a new script in a prim's inventory. The call is asynchronous — the viewer sends +`RezScript` to the simulator and waits for the inventory-changed callback before returning +the created item's details. The simulator may rename the item if a duplicate name exists. + +Notecard creation is not yet supported and will return an error. ```typescript interface ObjectItemCreateParams { - prim_id: string; + prim_id: string; // UUID of the prim to create the item in name: string; // Pure SL inventory name — no file extension - type: InventoryItemType; // "script" | "notecard" - vm?: ScriptVM; // Scripts only: target VM. Defaults to viewer default if absent. - content?: string; // Initial content. Empty item created if absent. + type: InventoryItemType; // "script" ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "lsl" } -interface ObjectItemCreateResponse { - success: boolean; - item_id?: string; // UUID of the created item (on success) - item?: ObjectInventoryItem; // Full item metadata including assigned subtype (on success) - message?: string; // Error description (on failure) +// On success, returns an ObjectInventoryItem with prim_id: +interface ObjectItemCreateResponse extends ObjectInventoryItem { + prim_id: string; // Echoed prim UUID } ``` +**Notes:** +- The response matches the `ObjectInventoryItem` structure (same fields as items in + `object.publish` and `object.update` notifications). +- The `name` in the response may differ from the request if the simulator renamed it. +- An `object.update` notification will also fire for the prim (since inventory changed). +- Timeout: 30 seconds. Returns a JSON-RPC internal error if the simulator does not respond. + --- ### ObjectItemDelete @@ -919,7 +938,8 @@ interface ObjectItemDeleteParams { interface ObjectItemDeleteResponse { success: boolean; - message?: string; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request } ``` diff --git a/src/synchservice.ts b/src/synchservice.ts index 4611466..3778377 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -30,6 +30,7 @@ import { showInfoMessage, showStatusMessage, showWarningMessage, + logDebug, logInfo, VSCodeHost, closeTextDocument, @@ -361,9 +362,18 @@ export class SynchService implements vscode.Disposable { onCompilationResult: (message: CompilationResult): any => this.onCompilationResult(message), onRuntimeDebug: (message: RuntimeDebug): any => this.onRuntimeDebug(message), onRuntimeError: (message: RuntimeError): any => this.onRuntimeError(message), - onObjectPublish: (msg: ObjectPublishMessage): any => ObjectContentService.getInstance().handlePublish(msg), - onObjectUnpublish: (msg: ObjectUnpublishMessage): any => ObjectContentService.getInstance().handleUnpublish(msg), - onObjectUpdate: (msg: ObjectUpdateMessage): any => ObjectContentService.getInstance().handleUpdate(msg), + onObjectPublish: (msg: ObjectPublishMessage): any => { + logDebug(`[object.publish] object_id=${msg.object.object_id}`); + ObjectContentService.getInstance().handlePublish(msg); + }, + onObjectUnpublish: (msg: ObjectUnpublishMessage): any => { + logDebug(`[object.unpublish] object_id=${msg.object_id}`); + ObjectContentService.getInstance().handleUnpublish(msg); + }, + onObjectUpdate: (msg: ObjectUpdateMessage): any => { + logDebug(`[object.update] object_id=${msg.object_id}`); + ObjectContentService.getInstance().handleUpdate(msg); + }, }; if (this.websocket && this.websocket.isConnected()) { @@ -1005,8 +1015,14 @@ export class SynchService implements vscode.Disposable { if (objectId && this.websocket?.isConnected()) { const result = await this.websocket.requestObject({ object_id: objectId }); - if (!result.success) { + if (result.object) { + logDebug(`[object.request] response contained object_id=${result.object.object_id}`); + ObjectContentService.getInstance().handlePublish({ object: result.object }); + } else if (result.success === false) { showWarningMessage(`Failed to request object: ${result.message ?? "unknown error"}`); + } else { + // Keep this visible while we support mixed viewer versions. + logDebug("[object.request] response contained no object payload; waiting for object.publish notification"); } } diff --git a/src/vscode/ObjectContentDecorator.ts b/src/vscode/ObjectContentDecorator.ts new file mode 100644 index 0000000..c23585a --- /dev/null +++ b/src/vscode/ObjectContentDecorator.ts @@ -0,0 +1,86 @@ +/** + * @file ObjectContentDecorator.ts + * File decoration provider for sl:// virtual filesystem entries. + * Shows visual indicators for connection state, script running state, etc. + * Copyright (C) 2025, Linden Research, Inc. + */ +import { + FileDecorationProvider, + Uri, + FileDecoration, + EventEmitter, + ProviderResult, + CancellationToken, + ThemeColor, + Disposable, +} from "vscode"; +import { SL_SCHEME } from "./objectcontentprovider"; +import { logDebug } from "../utils"; + +/** + * Provides file decorations for sl:// URIs based on connection state. + * When disconnected from the viewer, shows a red badge and tooltip. + */ +export class ObjectContentDecorator implements FileDecorationProvider, Disposable { + private _onDidChangeFileDecorations = new EventEmitter(); + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event; + + private isConnected: boolean = false; + private disposables: Disposable[] = []; + + constructor( + private readonly getConnectionState: () => boolean, + onConnectionChange: (listener: (connected: boolean) => void) => Disposable, + ) { + this.isConnected = getConnectionState(); + logDebug(`[ObjectContentDecorator] Initial connection state: ${this.isConnected}`); + + // Listen for connection state changes + const subscription = onConnectionChange((connected) => { + logDebug(`[ObjectContentDecorator] Connection state changed: ${connected}`); + this.isConnected = connected; + // Refresh all sl:// decorations (deferred to ensure processing when not focused) + setTimeout(() => { + this._onDidChangeFileDecorations.fire(undefined); + }, 0); + }); + this.disposables.push( + subscription, + this._onDidChangeFileDecorations, + ); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } + + provideFileDecoration(uri: Uri, _token: CancellationToken): ProviderResult { + // Only decorate sl:// URIs + if (uri.scheme !== SL_SCHEME) { + return undefined; + } + + // When disconnected, show red badge with warning + if (!this.isConnected) { + return { + badge: "⚠", + tooltip: "Not connected to Second Life viewer", + color: new ThemeColor("errorForeground"), + }; + } + + // Connected state - no special decoration for now + // Future: could show script running state, dirty state, etc. + return undefined; + } + + /** + * Manually trigger a refresh of decorations for specific URIs or all sl:// URIs. + */ + public refresh(uri?: Uri | Uri[]): void { + this._onDidChangeFileDecorations.fire(uri); + } +} diff --git a/src/vscode/objectcontentinterfaces.ts b/src/vscode/objectcontentinterfaces.ts new file mode 100644 index 0000000..d4ee775 --- /dev/null +++ b/src/vscode/objectcontentinterfaces.ts @@ -0,0 +1,238 @@ +/** + * @file objectcontentinterfaces.ts + * Interfaces for object content publishing feature + * Copyright (C) 2025, Linden Research, Inc. + */ + +// ============================================ +// Core Data Types +// ============================================ + +/** + * Types of inventory items supported by the object content provider. + * Only scripts and notecards are supported; other item types are not exposed. + */ +export type InventoryItemType = "script" | "notecard"; + +/** Script virtual machine / compiler target */ +export type ScriptVM = "lsl2" | "mono" | "luau"; + +/** Save-time compile target accepted by object.content.save */ +export type ObjectContentSaveVM = "mono" | "lsl2" | "luau"; + +/** + * Permission masks from viewer's LLPermissions. + * Only owner and next_owner masks are transmitted. + * Bit flags: PERM_MODIFY=0x4000, PERM_COPY=0x8000, PERM_TRANSFER=0x2000 + */ +export interface ItemPermissions { + owner: number; // Current owner's permission mask + next_owner: number; // Applied on transfer +} + +/** + * Inventory item within an object or linked prim. + * Only scripts and notecards are supported; other item types are not exposed. + * Note: asset_id is intentionally not transmitted for security. + */ +export interface ObjectInventoryItem { + item_id: string; // Inventory item UUID (unique within container) + name: string; // Display name + description?: string; // Item description + type: InventoryItemType; + /** Scripts only: script language subtype from viewer's II_FLAGS_SUBTYPE_MASK (0=LSL, 1=Luau) */ + subtype?: number; + /** Scripts only: which VM the script targets/was compiled for (from script metadata) */ + vm?: ScriptVM; + /** For scripts only: whether the script is currently running */ + running?: boolean; + permissions?: ItemPermissions; + creator_id?: string; // Creator UUID +} + +/** + * A linked prim within a linkset + */ +export interface LinkedObject { + link_id: string; // UUID of the linked prim + link_number: number; // Link number (2+ = children; root is 1) + link_name: string; // Display name of the linked prim + link_description?: string; + inventory: ObjectInventoryItem[]; +} + +/** + * Permission masks for the object itself + */ +export interface ObjectPermissions { + owner: number; + next_owner: number; +} + +/** + * Published object container (root of a linkset) + */ +export interface PublishedObject { + object_id: string; // UUID of the root prim + object_name: string; // Display name of root prim + object_description?: string; + region?: string; // Region where object exists + owner_id?: string; // Owner UUID + permissions?: ObjectPermissions; + inventory: ObjectInventoryItem[]; // Root prim inventory (scripts and notecards only) + linked_objects?: LinkedObject[]; // Child prims in linkset +} + +// ============================================ +// WebSocket Message Interfaces +// ============================================ + +/** object.publish — Viewer → Extension (notification) */ +export interface ObjectPublishMessage { + object: PublishedObject; +} + +/** object.unpublish — Viewer → Extension (notification) */ +export interface ObjectUnpublishMessage { + object_id: string; + reason?: string; +} + +/** Delta changes for inventory items */ +export interface InventoryChanges { + added?: ObjectInventoryItem[]; + removed?: string[]; // item_ids + modified?: ObjectInventoryItem[]; // metadata changes + content_changed?: string[]; // item_ids (invalidates cache) + running_changed?: { item_id: string; running: boolean }[]; +} + +/** Delta changes for linked objects */ +export interface LinkedObjectChanges { + added?: LinkedObject[]; + removed?: string[]; // link_ids + modified?: { + link_id: string; + link_name?: string; + inventory?: InventoryChanges; + }[]; +} + +/** + * object.update — Viewer → Extension (notification) + * Supports full replacement or delta-based updates. + * If `changes` is present it takes precedence over full replacement fields. + */ +export interface ObjectUpdateMessage { + object_id: string; + object_name?: string; + // Full replacement + inventory?: ObjectInventoryItem[]; + linked_objects?: LinkedObject[]; + // Delta + changes?: { + inventory?: InventoryChanges; + linked_objects?: LinkedObjectChanges; + }; +} + +/** object.content.get — Extension → Viewer (call) */ +export interface ObjectContentGetParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; +} + +/** object.content.get response */ +export interface ObjectContentGetResponse { + prim_id: string; + item_id: string; + content: string; + encoding?: "utf-8" | "base64"; +} + +/** object.content.save — Extension → Viewer (call) */ +export interface ObjectContentSaveParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; + content: string; + vm?: ObjectContentSaveVM; // Scripts only: explicit compile target +} + +/** object.content.save response */ +export interface ObjectContentSaveResponse { + success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; // Scripts only: true if compilation succeeded + errors?: string[]; // Scripts only: compiler diagnostics when compiled is false + message?: string; +} + +/** object.item.create — Extension → Viewer (call) */ +export interface ObjectItemCreateParams { + prim_id: string; // UUID of any prim (root or child) + name: string; // Item name (no extension — pure SL inventory name) + type: InventoryItemType; // "script" for current create flow ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "mono" | "lsl2" +} + +/** object.item.create response */ +export interface ObjectItemCreateResponse extends ObjectInventoryItem { + prim_id: string; // Echoed prim UUID +} + +/** object.item.delete — Extension → Viewer (call) */ +export interface ObjectItemDeleteParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; +} + +/** object.item.delete response */ +export interface ObjectItemDeleteResponse { + success: boolean; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request +} + +/** object.script.set_running — Extension → Viewer (call) */ +export interface ObjectScriptSetRunningParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; + running: boolean; // true = start, false = stop +} + +/** object.script.set_running response */ +export interface ObjectScriptSetRunningResponse { + success: boolean; + message?: string; +} + +/** object.request — Extension → Viewer (call) */ +export interface ObjectRequestParams { + object_id: string; // UUID of the root prim to request publishing for +} + +/** object.request response */ +export interface ObjectRequestResponse { + object?: PublishedObject; // Primary response payload for requested object + success?: boolean; // Legacy compatibility for older viewers + message?: string; // reason on failure (e.g. "object not found", "permission denied") +} + +// ============================================ +// Internal Service Types (not transmitted) +// ============================================ + +/** Cached content for an inventory item */ +export interface CachedItemContent { + content: Uint8Array; + mtime: number; + dirty: boolean; +} + +/** Internal representation of a published object with content cache */ +export interface ObjectEntry { + object: PublishedObject; + contentCache: Map; // keyed by item_id + publishedAt: number; +} diff --git a/src/vscode/objectcontentprovider.ts b/src/vscode/objectcontentprovider.ts new file mode 100644 index 0000000..aaddf2a --- /dev/null +++ b/src/vscode/objectcontentprovider.ts @@ -0,0 +1,587 @@ +/** + * @file objectcontentprovider.ts + * VS Code FileSystemProvider for the sl:// virtual filesystem. + * Presents published Second Life in-world objects as browseable directories. + * Copyright (C) 2025, Linden Research, Inc. + */ +import * as vscode from "vscode"; +import { ObjectContentService } from "./objectcontentservice"; +import { ViewerEditWSClient } from "../viewereditwsclient"; +import { + InventoryItemType, + ObjectContentSaveVM, + ObjectInventoryItem, + ScriptVM, +} from "./objectcontentinterfaces"; + +// ============================================ +// Constants +// ============================================ + +export const SL_SCHEME = "sl"; +export const SL_AUTHORITY = "objects"; + +/** PERM_MODIFY bit from viewer LLPermissions */ +const PERM_MODIFY = 0x4000; + +// JSON-RPC error codes used by the viewer +const JSONRPC_INVALID_PARAMS = -32602; +const JSONRPC_FORBIDDEN = -32003; +const JSONRPC_TIMEOUT = -32001; +const JSONRPC_INTERNAL_ERROR = -32603; + +/** + * Extract JSON-RPC error code from error message. + * The websocket client formats errors as "JSON-RPC Error {code}: {message}" + */ +function extractJsonRpcErrorCode(error: Error): number | undefined { + const match = error.message.match(/^JSON-RPC Error (-?\d+):/); + return match ? parseInt(match[1], 10) : undefined; +} + +/** + * Map JSON-RPC error to appropriate FileSystemError. + */ +function mapRpcErrorToFileSystemError(error: unknown, uri: vscode.Uri): Error { + if (!(error instanceof Error)) { + return vscode.FileSystemError.Unavailable(uri); + } + + const code = extractJsonRpcErrorCode(error); + switch (code) { + case JSONRPC_INVALID_PARAMS: + // Prim not found, item not found, or invalid item type + return vscode.FileSystemError.FileNotFound(uri); + case JSONRPC_FORBIDDEN: + // Object not published or insufficient permissions + return vscode.FileSystemError.NoPermissions(uri); + case JSONRPC_TIMEOUT: + // Simulator didn't respond in time + return vscode.FileSystemError.Unavailable(`Request timed out: ${uri}`); + case JSONRPC_INTERNAL_ERROR: + // Asset cache issue or other internal error + return vscode.FileSystemError.Unavailable(error.message); + default: + // Pass through the original error message + return vscode.FileSystemError.Unavailable(error.message); + } +} + +// ============================================ +// URI Helpers +// ============================================ + +interface ParsedObjectUri { + /** Root prim UUID (always present) */ + root_id: string; + /** Child prim UUID if path has 2+ segments, otherwise undefined */ + link_id?: string; + /** Item UUID if this is a file URI, otherwise undefined */ + item_id?: string; + /** Unresolved leaf filename for create flows */ + pending_name?: string; + /** Whether this URI refers to a directory */ + isDirectory: boolean; +} + +interface ParseUriOptions { + allowMissingLeaf?: boolean; +} + +/** Check if a string looks like a UUID */ +function isUUID(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + +/** + * Find an item in an inventory list by its display name. + * Returns the item_id if found, undefined otherwise. + */ +function findItemByDisplayName( + inventory: ObjectInventoryItem[] | undefined, + filename: string +): string | undefined { + if (!inventory) return undefined; + const item = inventory.find((i) => displayName(i) === filename); + return item?.item_id; +} + +/** + * Parse an sl://objects/... URI into its components. + * + * URI shapes: + * sl://objects/{root_id} → root directory + * sl://objects/{root_id}/{seg} → file in root (item_id or display name) + * OR child prim directory (link_id = seg) + * sl://objects/{root_id}/{link_id}/{seg} → file in child prim (item_id or display name) + * + * For file URIs, the segment can be either: + * - A UUID (item_id directly) — used by API calls + * - A display name (e.g., "Hello World.lsl") — used by Explorer + * + * Directories are distinguished from files by checking the object tree. + */ +function parseUri( + uri: vscode.Uri, + service: ObjectContentService, + options?: ParseUriOptions +): ParsedObjectUri { + // Strip leading slash and split + const parts = uri.path.replace(/^\//, "").split("/").filter((p) => p.length > 0); + + if (parts.length === 0) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const root_id = parts[0]; + const entry = service.getObject(root_id); + + if (parts.length === 1) { + return { root_id, isDirectory: true }; + } + + const seg1 = parts[1]; + + if (parts.length === 2) { + // Is seg1 a linked prim (directory) or an item (file)? + const isLinkedPrim = + entry?.object.linked_objects?.some((lo) => lo.link_id === seg1) ?? false; + + if (isLinkedPrim) { + return { root_id, link_id: seg1, isDirectory: true }; + } + + // seg1 is either a UUID or a display name + let item_id: string | undefined; + if (isUUID(seg1)) { + item_id = seg1; + } else { + item_id = findItemByDisplayName(entry?.object.inventory, seg1); + } + if (!item_id) { + if (options?.allowMissingLeaf) { + return { root_id, pending_name: seg1, isDirectory: false }; + } + throw vscode.FileSystemError.FileNotFound(uri); + } + return { root_id, item_id, isDirectory: false }; + } + + if (parts.length === 3) { + const link_id = parts[1]; + const seg2 = parts[2]; + + // seg2 is either a UUID or a display name + let item_id: string | undefined; + if (isUUID(seg2)) { + item_id = seg2; + } else { + const linkedObj = entry?.object.linked_objects?.find((lo) => lo.link_id === link_id); + item_id = findItemByDisplayName(linkedObj?.inventory, seg2); + } + if (!item_id) { + if (options?.allowMissingLeaf) { + return { root_id, link_id, pending_name: seg2, isDirectory: false }; + } + throw vscode.FileSystemError.FileNotFound(uri); + } + return { root_id, link_id, item_id, isDirectory: false }; + } + + throw vscode.FileSystemError.FileNotFound(uri); +} + +/** Build a URI for a root prim directory */ +export function rootUri(root_id: string): vscode.Uri { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}` }); +} + +/** Build a URI for a linked prim directory */ +export function linkedPrimUri(root_id: string, link_id: string): vscode.Uri { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${link_id}` }); +} + +/** Build a URI for a file (item in root or linked prim) */ +export function itemUri(root_id: string, prim_id: string, item_id: string): vscode.Uri { + if (prim_id === root_id) { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${item_id}` }); + } + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${prim_id}/${item_id}` }); +} + +// ============================================ +// Display Name Helpers +// ============================================ + +/** Map item subtype to the appropriate file extension for display */ +function extensionForItem(item: ObjectInventoryItem): string { + if (item.type === "notecard") return ".txt"; + return item.subtype === 1 ? ".luau" : ".lsl"; +} + +/** Returns the display filename (name + synthetic extension) */ +function displayName(item: ObjectInventoryItem): string { + return item.name + extensionForItem(item); +} + +/** + * Derive type and vm from a synthetic display extension. + * Used when creating new items from a filename the user typed. + */ +function typeAndVmFromExtension(ext: string): { type: InventoryItemType; vm: ScriptVM } | undefined { + switch (ext.toLowerCase()) { + case ".luau": return { type: "script", vm: "luau" }; + case ".lsl": return { type: "script", vm: "lsl2" }; + default: return undefined; + } +} + +/** + * Derive object.content.save vm from current script metadata. + * Save API accepts: mono, lsl2, luau. + */ +function saveVmForItem(item: ObjectInventoryItem): ObjectContentSaveVM | undefined { + if (item.type !== "script") { + return undefined; + } + + // Prefer explicit VM from metadata. + if (item.vm === "luau") { + return "luau"; + } + + if (item.vm === "lsl2") { + return "lsl2"; + } + + if (item.vm === "mono") { + return "mono"; + } + + // Compatibility fallback when VM metadata is absent. + if (item.subtype === 1) { + return "luau"; + } + + return "mono"; +} + +/** Strip the synthetic display extension to recover the raw SL inventory name */ +function stripExtension(filename: string): { name: string; ext: string } { + const dot = filename.lastIndexOf("."); + if (dot === -1) return { name: filename, ext: "" }; + return { name: filename.slice(0, dot), ext: filename.slice(dot) }; +} + +// ============================================ +// FileSystemProvider +// ============================================ + +export class ObjectContentProvider implements vscode.FileSystemProvider, vscode.Disposable { + private _onDidChangeFile = new vscode.EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly service: ObjectContentService, + private readonly getClient: () => ViewerEditWSClient | undefined, + ) { + // Forward content invalidations to VS Code as Changed events + this.disposables.push( + service.onDidChangeContent(({ object_id, prim_id, item_id }) => { + const uri = itemUri(object_id, prim_id, item_id); + // Defer to ensure VS Code processes even when not focused + setTimeout(() => { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + }, 0); + }), + + // Forward tree changes (added/removed objects) as directory changes + service.onDidChangeObjects(({ type, object_id }) => { + const uri = rootUri(object_id); + // Defer to ensure VS Code processes even when not focused + setTimeout(() => { + if (type === "added") { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Created, uri }]); + } else if (type === "removed") { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Deleted, uri }]); + } else { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + }, 0); + }), + + this._onDidChangeFile, + ); + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + this.disposables = []; + } + + // ============================================ + // FileSystemProvider — required methods + // ============================================ + + watch(_uri: vscode.Uri): vscode.Disposable { + // Change notifications are pushed from the service; no polling needed. + return new vscode.Disposable(() => { }); + } + + stat(uri: vscode.Uri): vscode.FileStat { + const parsed = parseUri(uri, this.service); + + if (parsed.isDirectory) { + return { + type: vscode.FileType.Directory, + ctime: 0, + mtime: 0, + size: 0, + }; + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + const item = this.service.getItem(root_id, prim_id, item_id!); + if (!item) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const cached = this.service.getCachedContent(root_id, item_id!); + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + + return { + type: vscode.FileType.File, + ctime: this.service.getObject(root_id)?.publishedAt ?? 0, + mtime: cached?.mtime ?? 0, + size: cached?.content.byteLength ?? 0, + permissions: canModify ? undefined : vscode.FilePermission.Readonly, + }; + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + const parsed = parseUri(uri, this.service); + if (!parsed.isDirectory) { + throw vscode.FileSystemError.FileNotADirectory(uri); + } + + const { root_id, link_id } = parsed; + const entry = this.service.getObject(root_id); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const results: [string, vscode.FileType][] = []; + + if (link_id) { + // Listing a child prim directory + const lo = this.service.getLinkedObject(root_id, link_id); + if (!lo) { + throw vscode.FileSystemError.FileNotFound(uri); + } + for (const item of lo.inventory) { + results.push([displayName(item), vscode.FileType.File]); + } + } else { + // Listing the root prim directory + for (const item of entry.object.inventory) { + results.push([displayName(item), vscode.FileType.File]); + } + for (const lo of entry.object.linked_objects ?? []) { + // Use link_id (UUID) as the directory name so URIs remain stable. + // link_name is used as display label via workspace folder naming. + results.push([lo.link_id, vscode.FileType.Directory]); + } + } + + return results; + } + + async readFile(uri: vscode.Uri): Promise { + const parsed = parseUri(uri, this.service); + if (parsed.isDirectory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + + // Return cached content if available + const cached = this.service.getCachedContent(root_id, item_id!); + if (cached) { + return cached.content; + } + + // Fetch from viewer + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const response = await client.getObjectContent({ prim_id, item_id: item_id! }); + const text = response.content ?? ""; + const bytes = Buffer.from(text, "utf-8"); + this.service.cacheContent(root_id, item_id!, bytes); + return bytes; + } catch (error) { + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { create: boolean; overwrite: boolean }, + ): Promise { + const parsed = parseUri(uri, this.service, { allowMissingLeaf: options.create }); + if (parsed.isDirectory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + const { root_id, link_id } = parsed; + const prim_id = link_id ?? root_id; + + // Permission check (only meaningful for existing items) + const entry = this.service.getObject(root_id); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + if (parsed.item_id) { + // item_id known — existing item + const item = this.service.getItem(root_id, prim_id, parsed.item_id); + if (item) { + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + if (!canModify) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const text = Buffer.from(content).toString("utf-8"); + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const vm = saveVmForItem(item); + const result = await client.saveObjectContent({ + prim_id, + item_id: parsed.item_id, + content: text, + vm, + }); + + if (!result.success) { + throw vscode.FileSystemError.Unavailable( + result.message ?? "Save failed" + ); + } + + if (result.compiled === false) { + const diagnostics = (result.errors ?? []).slice(0, 5).join("\n"); + const details = diagnostics.length > 0 + ? `\n${diagnostics}` + : ""; + void vscode.window.showWarningMessage( + `Second Life: Saved, but compilation failed.${details}` + ); + } + + this.service.cacheContent(root_id, parsed.item_id, content); + this.service.markContentSaved(root_id, parsed.item_id); + return; + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + } + + // No existing item — create new + if (!options.create) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const filename = parsed.pending_name + ?? uri.path.replace(/^\//, "").split("/").slice(-1)[0]; + const { name, ext } = stripExtension(filename); + const createParams = typeAndVmFromExtension(ext); + if (!createParams) { + throw vscode.FileSystemError.NoPermissions( + "Only script creation is currently supported (.lsl, .luau)." + ); + } + + const { type, vm } = createParams; + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const result = await client.createObjectItem({ + prim_id, + name, + type, + vm, + }); + + if (!result.item_id) { + throw vscode.FileSystemError.Unavailable("Create failed: missing item_id"); + } + + const response = await client.getObjectContent({ prim_id, item_id: result.item_id }); + const text = response.content ?? ""; + // Cache under the real item_id returned by the viewer + this.service.cacheContent(root_id, result.item_id, Buffer.from(text, "utf-8")); + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + async delete(uri: vscode.Uri, _options: { recursive: boolean }): Promise { + const parsed = parseUri(uri, this.service); + if (parsed.isDirectory) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + + const item = this.service.getItem(root_id, prim_id, item_id!); + if (!item) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + if (!canModify) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const result = await client.deleteObjectItem({ prim_id, item_id: item_id! }); + if (!result.success) { + throw vscode.FileSystemError.Unavailable("Delete failed"); + } + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + // Creating directories (linked prims) and renaming are not supported + createDirectory(_uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(_uri); + } + + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(_oldUri); + } +} diff --git a/src/vscode/objectcontentservice.ts b/src/vscode/objectcontentservice.ts new file mode 100644 index 0000000..c0aa513 --- /dev/null +++ b/src/vscode/objectcontentservice.ts @@ -0,0 +1,332 @@ +/** + * @file objectcontentservice.ts + * Singleton service managing published in-world object content. + * Copyright (C) 2025, Linden Research, Inc. + */ +import * as vscode from "vscode"; +import { + ObjectInventoryItem, + LinkedObject, + ObjectEntry, + CachedItemContent, + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, + InventoryChanges, +} from "./objectcontentinterfaces"; + +// ============================================ +// Event Types +// ============================================ + +/** Fired when objects are added, removed, or have metadata/inventory changes */ +export interface ObjectTreeChangeEvent { + type: "added" | "removed" | "updated"; + object_id: string; +} + +/** Fired when cached content for a specific item is invalidated or needs refresh */ +export interface ObjectContentChangeEvent { + object_id: string; + prim_id: string; + item_id: string; +} + +// ============================================ +// Service +// ============================================ + +export class ObjectContentService implements vscode.Disposable { + private static instance: ObjectContentService | undefined; + + private objects: Map = new Map(); + + private _onDidChangeObjects = new vscode.EventEmitter(); + readonly onDidChangeObjects = this._onDidChangeObjects.event; + + private _onDidChangeContent = new vscode.EventEmitter(); + readonly onDidChangeContent = this._onDidChangeContent.event; + + private disposables: vscode.Disposable[] = []; + + private constructor() { + this.disposables.push(this._onDidChangeObjects, this._onDidChangeContent); + } + + static getInstance(): ObjectContentService { + if (!ObjectContentService.instance) { + ObjectContentService.instance = new ObjectContentService(); + } + return ObjectContentService.instance; + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + if (ObjectContentService.instance === this) { + ObjectContentService.instance = undefined; + } + } + + // ============================================ + // Message Handlers (Viewer → Extension) + // ============================================ + + handlePublish(msg: ObjectPublishMessage): void { + const entry: ObjectEntry = { + object: msg.object, + contentCache: new Map(), + publishedAt: Date.now(), + }; + this.objects.set(msg.object.object_id, entry); + this._onDidChangeObjects.fire({ type: "added", object_id: msg.object.object_id }); + } + + handleUnpublish(msg: ObjectUnpublishMessage): void { + if (this.objects.delete(msg.object_id)) { + this._onDidChangeObjects.fire({ type: "removed", object_id: msg.object_id }); + } + } + + handleUpdate(msg: ObjectUpdateMessage): void { + const entry = this.objects.get(msg.object_id); + if (!entry) { + return; + } + + if (msg.object_name !== undefined) { + entry.object.object_name = msg.object_name; + } + + if (msg.changes) { + // Delta update + if (msg.changes.inventory) { + this._applyInventoryChanges( + entry, + msg.object_id, + msg.object_id, + entry.object.inventory, + msg.changes.inventory + ); + } + if (msg.changes.linked_objects) { + const lc = msg.changes.linked_objects; + if (lc.added) { + entry.object.linked_objects = [ + ...(entry.object.linked_objects ?? []), + ...lc.added, + ]; + } + if (lc.removed) { + const removedSet = new Set(lc.removed); + entry.object.linked_objects = (entry.object.linked_objects ?? []).filter( + (lo) => !removedSet.has(lo.link_id) + ); + // Evict cached content for removed linked prims + for (const link_id of lc.removed) { + this._evictPrimCache(entry, msg.object_id, link_id); + } + } + if (lc.modified) { + for (const mod of lc.modified) { + const lo = (entry.object.linked_objects ?? []).find( + (l) => l.link_id === mod.link_id + ); + if (!lo) continue; + if (mod.link_name !== undefined) { + lo.link_name = mod.link_name; + } + if (mod.inventory) { + this._applyInventoryChanges( + entry, + msg.object_id, + mod.link_id, + lo.inventory, + mod.inventory + ); + } + } + } + } + } else { + // Full replacement + if (msg.inventory !== undefined) { + entry.object.inventory = msg.inventory; + // Evict root prim content cache entirely + this._evictPrimCache(entry, msg.object_id, msg.object_id); + } + if (msg.linked_objects !== undefined) { + // Evict cache for all replaced linked prims + for (const lo of entry.object.linked_objects ?? []) { + this._evictPrimCache(entry, msg.object_id, lo.link_id); + } + entry.object.linked_objects = msg.linked_objects; + } + } + + this._onDidChangeObjects.fire({ type: "updated", object_id: msg.object_id }); + } + + // ============================================ + // Tree Queries + // ============================================ + + getObjects(): readonly ObjectEntry[] { + return Array.from(this.objects.values()); + } + + getObject(object_id: string): ObjectEntry | undefined { + return this.objects.get(object_id); + } + + hasObject(object_id: string): boolean { + return this.objects.has(object_id); + } + + /** + * Returns inventory for any prim in the linkset. + * Pass prim_id === object_id for root prim, or a link_id for a child prim. + */ + getInventory(object_id: string, prim_id: string): ObjectInventoryItem[] | undefined { + const entry = this.objects.get(object_id); + if (!entry) return undefined; + if (prim_id === object_id) { + return entry.object.inventory; + } + return entry.object.linked_objects?.find((lo) => lo.link_id === prim_id)?.inventory; + } + + getItem(object_id: string, prim_id: string, item_id: string): ObjectInventoryItem | undefined { + return this.getInventory(object_id, prim_id)?.find((i) => i.item_id === item_id); + } + + getLinkedObject(object_id: string, link_id: string): LinkedObject | undefined { + return this.objects.get(object_id)?.object.linked_objects?.find( + (lo) => lo.link_id === link_id + ); + } + + // ============================================ + // Content Cache + // ============================================ + + getCachedContent(object_id: string, item_id: string): CachedItemContent | undefined { + return this.objects.get(object_id)?.contentCache.get(item_id); + } + + cacheContent(object_id: string, item_id: string, content: Uint8Array): void { + const entry = this.objects.get(object_id); + if (!entry) return; + const existing = entry.contentCache.get(item_id); + entry.contentCache.set(item_id, { + content, + mtime: Date.now(), + dirty: existing?.dirty ?? false, + }); + } + + markContentDirty(object_id: string, item_id: string): void { + const cached = this.objects.get(object_id)?.contentCache.get(item_id); + if (cached) { + cached.dirty = true; + } + } + + markContentSaved(object_id: string, item_id: string): void { + const cached = this.objects.get(object_id)?.contentCache.get(item_id); + if (cached) { + cached.dirty = false; + } + } + + isContentDirty(object_id: string, item_id: string): boolean { + return this.objects.get(object_id)?.contentCache.get(item_id)?.dirty ?? false; + } + + // ============================================ + // Lifecycle + // ============================================ + + /** Remove all published objects (e.g. on viewer disconnect). */ + clear(): void { + const ids = Array.from(this.objects.keys()); + this.objects.clear(); + for (const object_id of ids) { + this._onDidChangeObjects.fire({ type: "removed", object_id }); + } + } + + // ============================================ + // Private Helpers + // ============================================ + + /** + * Apply delta inventory changes to an item list. + * Fires onDidChangeContent for any content_changed or removed items. + */ + private _applyInventoryChanges( + entry: ObjectEntry, + object_id: string, + prim_id: string, + inventory: ObjectInventoryItem[], + changes: InventoryChanges + ): void { + if (changes.added) { + inventory.push(...changes.added); + } + if (changes.removed) { + const removedSet = new Set(changes.removed); + for (let i = inventory.length - 1; i >= 0; i--) { + if (removedSet.has(inventory[i].item_id)) { + inventory.splice(i, 1); + } + } + for (const item_id of changes.removed) { + entry.contentCache.delete(item_id); + this._onDidChangeContent.fire({ object_id, prim_id, item_id }); + } + } + if (changes.modified) { + for (const mod of changes.modified) { + const idx = inventory.findIndex((i) => i.item_id === mod.item_id); + if (idx !== -1) { + inventory[idx] = mod; + } + } + } + if (changes.content_changed) { + for (const item_id of changes.content_changed) { + entry.contentCache.delete(item_id); + this._onDidChangeContent.fire({ object_id, prim_id, item_id }); + } + } + if (changes.running_changed) { + for (const rc of changes.running_changed) { + const item = inventory.find((i) => i.item_id === rc.item_id); + if (item) { + item.running = rc.running; + } + } + } + } + + /** + * Evict all cached content belonging to a specific prim from the entry's cache. + * Used when inventory is fully replaced or a linked prim is removed. + * Fires onDidChangeContent for each evicted item. + */ + private _evictPrimCache(entry: ObjectEntry, object_id: string, prim_id: string): void { + const inventory = + prim_id === object_id + ? entry.object.inventory + : entry.object.linked_objects?.find((lo) => lo.link_id === prim_id)?.inventory ?? []; + + for (const item of inventory) { + if (entry.contentCache.delete(item.item_id)) { + this._onDidChangeContent.fire({ object_id, prim_id, item_id: item.item_id }); + } + } + } +} diff --git a/src/websockclient.ts b/src/websockclient.ts index c2974e8..a7a51c7 100644 --- a/src/websockclient.ts +++ b/src/websockclient.ts @@ -64,6 +64,7 @@ import * as vscode from "vscode"; import WebSocket from "ws"; +import { logDebug } from "./utils"; /** * JSON-RPC 2.0 message types @@ -448,13 +449,47 @@ export class JSONRPCClient extends WebsockClient implements JSONRPCInterface { super(context, url); } + private logIncomingMessage(message: JSONRPCMessage): void { + if (this.isJSONRPCRequest(message)) { + logDebug(`[JSON-RPC] <- request method=${message.method} id=${String(message.id)}`); + return; + } + + if (this.isJSONRPCNotification(message)) { + logDebug(`[JSON-RPC] <- notification method=${message.method}`); + return; + } + + if (this.isJSONRPCResponse(message)) { + const status = message.error ? "error" : "result"; + logDebug(`[JSON-RPC] <- response id=${String(message.id)} status=${status}`); + } + } + + private logOutgoingMessage(message: JSONRPCMessage): void { + if (this.isJSONRPCRequest(message)) { + logDebug(`[JSON-RPC] -> request method=${message.method} id=${String(message.id)}`); + return; + } + + if (this.isJSONRPCNotification(message)) { + logDebug(`[JSON-RPC] -> notification method=${message.method}`); + return; + } + + if (this.isJSONRPCResponse(message)) { + const status = message.error ? "error" : "result"; + logDebug(`[JSON-RPC] -> response id=${String(message.id)} status=${status}`); + } + } + /** * Handles incoming WebSocket messages with JSON-RPC support */ protected handleMessage(data: WebSocket.RawData): void { try { const message = JSON.parse(data.toString()) as JSONRPCMessage; - // console.log("Received JSON-RPC message:", message); + this.logIncomingMessage(message); if (this.isJSONRPCResponse(message)) { this.handleJSONRPCResponse(message); @@ -674,6 +709,7 @@ export class JSONRPCClient extends WebsockClient implements JSONRPCInterface { * Sends a JSON-RPC message */ private sendJSONRPCMessage(message: JSONRPCMessage): boolean { + this.logOutgoingMessage(message); return this.sendMessage(message); } From 22ffedfd4bdcd3182b7c7a1025fa35767ee4bbce Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Thu, 7 May 2026 09:03:02 -0700 Subject: [PATCH 3/9] [WIP] Publishing and editing object scripts directly from the viewer. --- doc/Message_Interfaces.md | 402 +++++++++++++++++++++++++++++++++++++- package.json | 12 ++ src/extension.ts | 93 +++++++++ src/synchservice.ts | 87 ++++++++- src/utils.ts | 11 ++ src/viewereditwsclient.ts | 61 ++++++ 6 files changed, 661 insertions(+), 5 deletions(-) diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 52cb53f..45b725d 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -5,6 +5,7 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents - [Usage Flow](#usage-flow) +- [VS Code Launch URI](#vs-code-launch-uri) - [JSON-RPC Method Summary](#json-rpc-method-summary) - [Session Management Interfaces](#session-management-interfaces) - [SessionHandshake](#sessionhandshake) @@ -31,6 +32,17 @@ This document describes all the message interfaces defined for WebSocket communi - [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - [WebSocketHandlers](#websockethandlers) - [ClientInfo](#clientinfo) +- [Object Content Interfaces](#object-content-interfaces) + - [Core Data Types](#core-data-types) + - [ObjectPublish](#objectpublish) + - [ObjectUnpublish](#objectunpublish) + - [ObjectUpdate](#objectupdate) + - [ObjectContentGet](#objectcontentget) + - [ObjectContentSave](#objectcontentsave) + - [ObjectItemCreate](#objectitemcreate) + - [ObjectItemDelete](#objectitemdelete) + - [ObjectScriptSetRunning](#objectscriptsetrunning) + - [ObjectRequest](#objectrequest) ## Usage Flow @@ -53,17 +65,84 @@ This document describes all the message interfaces defined for WebSocket communi - When subscription needs to be terminated, viewer sends `script.unsubscribe` notification with `ScriptUnsubscribe` data - Extension handles unsubscription by cleaning up local script tracking -4. **Runtime Events:** +4. **Object Content Publishing:** + + - Viewer sends `object.publish` notification when an in-world object's contents are made available for editing + - Viewer sends `object.unpublish` notification when an object is removed or the owner stops publishing + - Viewer sends `object.update` notification when object inventory changes (full replacement or delta) + - Extension calls `object.content.get` to fetch an item's content on demand + - Extension calls `object.content.save` to write modified content back to the viewer + - Extension calls `object.item.create` / `object.item.delete` to manage inventory items + - Extension calls `object.script.set_running` to start or stop a script + +5. **Runtime Events:** - Viewer sends `language.syntax.change` notification with `SyntaxChange` when language changes - Viewer sends `script.compiled` notification with `CompilationResult` after script compilation - Viewer sends `runtime.debug` notification with `RuntimeDebug` for debug messages during script execution - Viewer sends `runtime.error` notification with `RuntimeError` when runtime errors occur -5. **Connection Termination:** +6. **Connection Termination:** - Either side can send `session.disconnect` notification with `SessionDisconnect` data - Connection is closed gracefully +## VS Code Launch URI + +The viewer can launch VS Code and trigger an automatic WebSocket connection by opening a `vscode://` URI via the operating system's default URI handler. The extension registers a URI handler for this scheme; VS Code will launch itself if not already running and deliver the URI to the extension. + +### URI Format + +``` +vscode://lindenlab.sl-vscode-plugin/connect[?port=][&object=][&script=] +``` + +### Parameters + +| Parameter | Required | Description | +| --------- | -------- | ----------- | +| `port` | No | Port number the viewer's WebSocket server is listening on. Overrides the user's configured port for this session. Defaults to the configured `slVscodeEdit.network.websocketPort` (default `9020`) if absent. Must be in range 1024–65535. | +| `object` | No | UUID of a root prim. After the handshake completes the extension calls `object.request` to ask the viewer to publish this object. The viewer then sends an `object.publish` notification and the object appears as a workspace folder in the Explorer. | +| `script` | No | UUID of a script. After the handshake completes the extension locates the corresponding temp file via `script.list` and opens it, triggering the normal `script.subscribe` + live-sync flow. | + +`object` and `script` are mutually exclusive in typical use but both may be supplied; the extension will process both. + +### Examples + +``` +# Open VS Code and connect on default port +vscode://lindenlab.sl-vscode-plugin/connect + +# Connect on a custom port +vscode://lindenlab.sl-vscode-plugin/connect?port=9021 + +# Connect and immediately publish a specific object +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&object=550e8400-e29b-41d4-a716-446655440000 + +# Connect and open a specific script for editing +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&script=6ba7b810-9dad-11d1-80b4-00c04fd430c8 +``` + +### Post-connection sequence + +When the URI contains an `object` or `script` parameter the extension acts only **after** the handshake is fully complete (`session.ok` received): + +``` +URI received by extension + │ + ▼ +WebSocket connects → session.handshake → session.ok + │ + ├─ object= → object.request({ object_id }) call + │ │ + │ ▼ (async, when viewer is ready) + │ object.publish notification + │ + └─ script= → script.list call → open temp file + │ + ▼ + script.subscribe + live-sync +``` + ## JSON-RPC Method Summary | Method | Direction | Type | Interface/Parameters | @@ -89,6 +168,21 @@ This document describes all the message interfaces defined for WebSocket communi | `script.compiled` | Viewer → Extension | Notification | `CompilationResult` | | `runtime.debug` | Viewer → Extension | Notification | `RuntimeDebug` | | `runtime.error` | Viewer → Extension | Notification | `RuntimeError` | +| `object.publish` | Viewer → Extension | Notification | `ObjectPublishMessage` | +| `object.unpublish` | Viewer → Extension | Notification | `ObjectUnpublishMessage` | +| `object.update` | Viewer → Extension | Notification | `ObjectUpdateMessage` | +| `object.content.get` | Extension → Viewer | Call | `ObjectContentGetParams` | +| `object.content.get` (response) | Viewer → Extension | Response | `ObjectContentGetResponse` | +| `object.content.save` | Extension → Viewer | Call | `ObjectContentSaveParams` | +| `object.content.save` (response)| Viewer → Extension | Response | `ObjectContentSaveResponse`| +| `object.item.create` | Extension → Viewer | Call | `ObjectItemCreateParams` | +| `object.item.create` (response) | Viewer → Extension | Response | `ObjectItemCreateResponse` | +| `object.item.delete` | Extension → Viewer | Call | `ObjectItemDeleteParams` | +| `object.item.delete` (response) | Viewer → Extension | Response | `ObjectItemDeleteResponse` | +| `object.script.set_running` | Extension → Viewer | Call | `ObjectScriptSetRunningParams` | +| `object.script.set_running` (response) | Viewer → Extension | Response | `ObjectScriptSetRunningResponse` | +| `object.request` | Extension → Viewer | Call | `ObjectRequestParams` | +| `object.request` (response) | Viewer → Extension | Response | `ObjectRequestResponse` | ## Session Management Interfaces @@ -577,3 +671,307 @@ interface ClientInfo { - `scriptName`: Name of the script being edited - `scriptId`: Unique identifier for the script - `extension`: File extension or script type + +--- + +## Object Content Interfaces + +These interfaces support publishing in-world object inventories (scripts and notecards) to the external editor as a browseable virtual filesystem. The extension exposes published objects under the `sl://objects/` URI scheme. + +### Core Data Types + +```typescript +type InventoryItemType = "script" | "notecard"; + +type ScriptVM = "lso" | "mono" | "luau"; + +/** Permission mask fields. Only owner and next_owner are transmitted. */ +interface ItemPermissions { + owner: number; // e.g. PERM_MODIFY=0x4000, PERM_COPY=0x8000, PERM_TRANSFER=0x2000 + next_owner: number; +} + +/** + * Inventory item within an object or linked prim. + * asset_id is intentionally never transmitted. + */ +interface ObjectInventoryItem { + item_id: string; // Inventory item UUID + name: string; // Display name (no file extension) + description?: string; + type: InventoryItemType; + subtype?: number; // Scripts only: language from II_FLAGS_SUBTYPE_MASK (0=LSL, 1=Luau) + vm?: ScriptVM; // Scripts only: which VM the script targets + running?: boolean; // Scripts only: whether the script is running + permissions?: ItemPermissions; + creator_id?: string; +} + +/** A linked (child) prim within a linkset */ +interface LinkedObject { + link_id: string; // UUID of the linked prim + link_number: number; // Link number (root=1, children≥2) + link_name: string; + link_description?: string; + inventory: ObjectInventoryItem[]; +} + +interface ObjectPermissions { + owner: number; + next_owner: number; +} + +/** Root of a linkset, as published to the extension */ +interface PublishedObject { + object_id: string; // UUID of the root prim + object_name: string; + object_description?: string; + region?: string; + owner_id?: string; + permissions?: ObjectPermissions; + inventory: ObjectInventoryItem[]; // Root prim's scripts and notecards + linked_objects?: LinkedObject[]; // Child prims +} +``` + +**Script display extensions** (synthetic, derived from `subtype`): + +| `subtype` | Extension | +| --------- | --------- | +| `0` (LSL) | `.lsl` | +| `1` (Luau)| `.luau` | +| notecard | `.txt` | + +--- + +### ObjectPublish + +**JSON-RPC Method:** `object.publish` (notification from viewer) + +Sent when the viewer publishes an in-world object's inventory for external editing. Triggers creation of a virtual filesystem workspace folder in the extension. + +```typescript +interface ObjectPublishMessage { + object: PublishedObject; +} +``` + +**Fields:** + +- `object`: The full published object tree, including root prim inventory and all linked prim inventories. + +--- + +### ObjectUnpublish + +**JSON-RPC Method:** `object.unpublish` (notification from viewer) + +Sent when the viewer removes a previously published object — for example when the owner deselects it, moves away, or the object is deleted. + +```typescript +interface ObjectUnpublishMessage { + object_id: string; + reason?: string; +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim that is being unpublished +- `reason` (optional): Human-readable explanation (e.g. `"object deleted"`, `"out of range"`) + +--- + +### ObjectUpdate + +**JSON-RPC Method:** `object.update` (notification from viewer) + +Sent when the inventory of a published object changes. Supports two modes: +- **Full replacement**: `inventory` and/or `linked_objects` fields replace the entire prior state. +- **Delta update**: `changes` field describes only what changed. Takes precedence over full replacement fields when present. + +```typescript +interface InventoryChanges { + added?: ObjectInventoryItem[]; + removed?: string[]; // item_ids removed + modified?: ObjectInventoryItem[]; // metadata-only changes + content_changed?: string[]; // item_ids whose content changed (invalidates cache) + running_changed?: { item_id: string; running: boolean }[]; // running state toggled +} + +interface LinkedObjectChanges { + added?: LinkedObject[]; + removed?: string[]; // link_ids removed + modified?: { + link_id: string; + link_name?: string; + inventory?: InventoryChanges; + }[]; +} + +interface ObjectUpdateMessage { + object_id: string; + object_name?: string; + // Full replacement (used when changes is absent) + inventory?: ObjectInventoryItem[]; + linked_objects?: LinkedObject[]; + // Delta (takes precedence when present) + changes?: { + inventory?: InventoryChanges; + linked_objects?: LinkedObjectChanges; + }; +} +``` + +--- + +### ObjectContentGet + +**JSON-RPC Method:** `object.content.get` (call from extension to viewer) + +Requests the text content of a script or notecard. The extension calls this lazily when the user opens a file in the virtual filesystem. + +```typescript +interface ObjectContentGetParams { + prim_id: string; // UUID of any prim (root or child) — no object_id + link_id needed + item_id: string; +} + +interface ObjectContentGetResponse { + prim_id: string; + item_id: string; + content: string; + encoding?: "utf-8" | "base64"; +} +``` + +**Fields:** + +- `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID without knowing the root object_id. +- `item_id`: Inventory item UUID. +- `content`: The raw text content of the item. +- `encoding` (optional): Encoding used for `content`. Defaults to `"utf-8"` if absent. + +--- + +### ObjectContentSave + +**JSON-RPC Method:** `object.content.save` (call from extension to viewer) + +Writes modified content back to the viewer. For scripts, the viewer will attempt to compile the updated source. + +```typescript +interface ObjectContentSaveParams { + prim_id: string; + item_id: string; + content: string; +} + +interface ObjectContentSaveResponse { + success: boolean; + message?: string; +} +``` + +**Fields:** + +- `success`: Whether the save (and compilation, if applicable) succeeded. +- `message` (optional): Error description on failure. + +--- + +### ObjectItemCreate + +**JSON-RPC Method:** `object.item.create` (call from extension to viewer) + +Creates a new script or notecard in a prim's inventory. + +```typescript +interface ObjectItemCreateParams { + prim_id: string; + name: string; // Pure SL inventory name — no file extension + type: InventoryItemType; // "script" | "notecard" + vm?: ScriptVM; // Scripts only: target VM. Defaults to viewer default if absent. + content?: string; // Initial content. Empty item created if absent. +} + +interface ObjectItemCreateResponse { + success: boolean; + item_id?: string; // UUID of the created item (on success) + item?: ObjectInventoryItem; // Full item metadata including assigned subtype (on success) + message?: string; // Error description (on failure) +} +``` + +--- + +### ObjectItemDelete + +**JSON-RPC Method:** `object.item.delete` (call from extension to viewer) + +Deletes a script or notecard from a prim's inventory. Requires `PERM_MODIFY` on the item. + +```typescript +interface ObjectItemDeleteParams { + prim_id: string; + item_id: string; +} + +interface ObjectItemDeleteResponse { + success: boolean; + message?: string; +} +``` + +--- + +### ObjectScriptSetRunning + +**JSON-RPC Method:** `object.script.set_running` (call from extension to viewer) + +Starts or stops a script within a prim. + +```typescript +interface ObjectScriptSetRunningParams { + prim_id: string; + item_id: string; + running: boolean; // true = start, false = stop +} + +interface ObjectScriptSetRunningResponse { + success: boolean; + message?: string; +} +``` + +--- + +### ObjectRequest + +**JSON-RPC Method:** `object.request` (call from extension to viewer) + +Requests the viewer to publish a specific in-world object. The viewer responds synchronously to confirm the request was accepted, then asynchronously sends an `object.publish` notification with the full object tree. + +This is typically called immediately after the handshake completes when the extension was launched by the viewer with an `object=` URI parameter. + +```typescript +interface ObjectRequestParams { + object_id: string; // UUID of the root prim to request publishing for +} + +interface ObjectRequestResponse { + success: boolean; + message?: string; // reason on failure (e.g. "object not found", "permission denied") +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim of the linkset to publish. +- `success`: Whether the viewer accepted the request. A `true` response does not mean `object.publish` has been sent yet — it means the viewer will send it. +- `message` (optional): Human-readable failure reason. Only present when `success` is `false`. + +**Sequence:** +1. Extension calls `object.request` +2. Viewer responds with `{ success: true }` (or error) +3. Viewer sends `object.publish` notification (asynchronously, when ready) diff --git a/package.json b/package.json index a0b48cc..27cb881 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,18 @@ "url": "https://github.com/secondlife/sl-vscode-plugin.git" }, "contributes": { + "resourceLabelFormatters": [ + { + "scheme": "sl", + "authority": "objects", + "formatting": { + "label": "${path}", + "separator": "/", + "tildify": false, + "workspaceSuffix": "Second Life" + } + } + ], "commands": [ { "command": "second-life-scripting.enable", diff --git a/src/extension.ts b/src/extension.ts index 4ad5f35..e5c92ce 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,12 +5,16 @@ import * as vscode from "vscode"; import { SynchService } from "./synchservice"; import { LanguageService } from "./shared/languageservice"; +import { ObjectContentService } from "./vscode/objectcontentservice"; +import { ObjectContentProvider, SL_SCHEME, SL_AUTHORITY, rootUri } from "./vscode/objectcontentprovider"; +import { ObjectContentDecorator } from "./vscode/ObjectContentDecorator"; import { ConfigService, configPrefix } from "./configservice"; import { VSCodeHost, getOutputChannel, showOutputChannel, logInfo, + logDebug, showStatusMessage, hasWorkspace, showErrorMessage @@ -28,6 +32,95 @@ export function activate(context: vscode.ExtensionContext): void { // Initialize the file sync functionality const synchService = SynchService.getInstance(context); + // Initialize object content service and register the sl:// FileSystemProvider + const objectContentService = ObjectContentService.getInstance(); + const objectContentProvider = new ObjectContentProvider( + objectContentService, + () => synchService.getWebSocket(), + ); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(SL_SCHEME, objectContentProvider, { + isCaseSensitive: true, + }), + objectContentProvider, + objectContentService, + ); + + // Register file decoration provider for sl:// URIs (shows disconnected state) + const objectContentDecorator = new ObjectContentDecorator( + () => synchService.isConnected(), + (listener) => synchService.onDidChangeConnectionState(listener), + ); + context.subscriptions.push( + vscode.window.registerFileDecorationProvider(objectContentDecorator), + objectContentDecorator, + ); + + // Manage workspace folders for published objects so Explorer shows friendly names + context.subscriptions.push( + objectContentService.onDidChangeObjects(({ type, object_id }) => { + const folders = vscode.workspace.workspaceFolders ?? []; + const slIdx = folders.findIndex( + (f) => f.uri.scheme === SL_SCHEME && f.uri.authority === SL_AUTHORITY && f.uri.path === `/${object_id}` + ); + if (type === "added") { + const entry = objectContentService.getObject(object_id); + if (entry && slIdx === -1) { + vscode.workspace.updateWorkspaceFolders(folders.length, 0, { + uri: rootUri(object_id), + name: entry.object.object_name, + }); + } + } else if (type === "removed") { + if (slIdx !== -1) { + vscode.workspace.updateWorkspaceFolders(slIdx, 1); + } + } + }) + ); + + // Register URI handler so the viewer can launch VS Code and trigger a connection. + // URI format: vscode://lindenlab.sl-vscode-plugin/connect?port=9020[&object=][&script=] + context.subscriptions.push( + vscode.window.registerUriHandler({ + handleUri(uri: vscode.Uri): void { + + logDebug(`Received URI: ${uri.toString()}`); + + if (uri.path !== "/connect") { return; } + + // Decode the query string first - some viewers incorrectly encode the delimiters + const decodedQuery = decodeURIComponent(uri.query); + logDebug(`Decoded query: ${decodedQuery}`); + + const query = Object.fromEntries( + decodedQuery.split("&").filter(Boolean).map(p => { + const eq = p.indexOf("="); + return eq === -1 + ? [p, ""] + : [p.slice(0, eq), p.slice(eq + 1)]; + }) + ); + + logDebug(`Parsed query: ${JSON.stringify(query)}`); + + const rawPort = query["port"] as string | undefined; + const port = rawPort !== undefined ? parseInt(rawPort, 10) : undefined; + if (port !== undefined && (isNaN(port) || port < 1024 || port > 65535)) { + showErrorMessage(`Second Life: Invalid port in launch URI: ${rawPort}`); + return; + } + + const object_id = query["object"] as string | undefined; + const script_id = query["script"] as string | undefined; + + logInfo(`Connecting with port=${port}, object_id=${object_id ?? "(none)"}, script_id=${script_id ?? "(none)"}`); + + synchService.connectToViewer({ port, object_id, script_id }); + } + }) + ); + // Register output channel for disposal context.subscriptions.push(getOutputChannel()); diff --git a/src/synchservice.ts b/src/synchservice.ts index 465cb9d..df80ade 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -20,6 +20,11 @@ import { RuntimeDebug, RuntimeError, } from "./viewereditwsclient"; +import { + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, +} from "./vscode/objectcontentinterfaces"; import { hasWorkspace, showInfoMessage, @@ -35,6 +40,7 @@ import { ScriptSync } from "./scriptsync"; import { getLanguageConfig } from "./shared/lexer"; import { HostInterface } from "./interfaces/hostinterface"; import { SyncedFileDecorator } from "./vscode/SyncedFileDecorator"; +import { ObjectContentService } from "./vscode/objectcontentservice"; type ParsedTempFile = { scriptName: string; scriptId: string; extension: string, language: ScriptLanguage }; @@ -50,6 +56,8 @@ export class SynchService implements vscode.Disposable { private activeSync: ScriptSync | undefined; private host: HostInterface; private initialGenerationDone: boolean = false; + private pendingLaunchObjectId?: string; + private pendingLaunchScriptId?: string; public viewerName?: string; public viewerVersion?: string; @@ -62,12 +70,17 @@ export class SynchService implements vscode.Disposable { private syncedFileDecorator : SyncedFileDecorator; + private _onDidChangeConnectionState = new vscode.EventEmitter(); + readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; + private disposables: vscode.Disposable[] = []; private constructor(context: vscode.ExtensionContext) { this.context = context; this.host = new VSCodeHost(); this.syncedFileDecorator = new SyncedFileDecorator(this); + // Note: _onDidChangeConnectionState is NOT added to disposables + // because it must survive activate/deactivate cycles } public static getInstance(context?: vscode.ExtensionContext): SynchService { @@ -337,7 +350,7 @@ export class SynchService implements vscode.Disposable { //==================================================================== //#region WebSocket connection management and handlers - private async setupConnection(): Promise { + private async setupConnection(portOverride?: number): Promise { const handlers = { onHandshake: (message: SessionHandshake): any => this.onHandshake(message), onHandshakeOk: (): any => this.onHandshakeOk(), @@ -348,7 +361,9 @@ export class SynchService implements vscode.Disposable { onCompilationResult: (message: CompilationResult): any => this.onCompilationResult(message), onRuntimeDebug: (message: RuntimeDebug): any => this.onRuntimeDebug(message), onRuntimeError: (message: RuntimeError): any => this.onRuntimeError(message), - + onObjectPublish: (msg: ObjectPublishMessage): any => ObjectContentService.getInstance().handlePublish(msg), + onObjectUnpublish: (msg: ObjectUnpublishMessage): any => ObjectContentService.getInstance().handleUnpublish(msg), + onObjectUpdate: (msg: ObjectUpdateMessage): any => ObjectContentService.getInstance().handleUpdate(msg), }; if (this.websocket && this.websocket.isConnected()) { @@ -359,9 +374,11 @@ export class SynchService implements vscode.Disposable { this.getHandshakePromise(); showStatusMessage("Connecting to Second Life viewer...", handshake); + const port = portOverride + ?? this.host.config.getConfig(ConfigKey.NetworkWebsocketPort, 9020); this.websocket = new ViewerEditWSClient( this.context, - `ws://localhost:${this.host.config.getConfig(ConfigKey.NetworkWebsocketPort, 9020)}` + `ws://localhost:${port}` ); this.websocket.setup(handlers); let connected = await this.websocket.connect(); @@ -424,6 +441,7 @@ export class SynchService implements vscode.Disposable { error_reporting: true, debugging: false, breakpoints: false, + object_publish: true, }, }; return response; @@ -456,6 +474,10 @@ export class SynchService implements vscode.Disposable { if (this.handshakeResolve) { this.handshakeResolve(true, "Connected"); } + + this._onDidChangeConnectionState.fire(true); + + await this.handleLaunchParams(); } private onDisconnect(params: SessionDisconnect): void { @@ -472,8 +494,11 @@ export class SynchService implements vscode.Disposable { ); } + this._onDidChangeConnectionState.fire(false); + // Don't dispose immediately - let the connection close handler do the cleanup // The websocket will be closed by the server, triggering our close handler + ObjectContentService.getInstance().clear(); } private onScriptUnsubscribe(message: ScriptUnsubscribe): void { @@ -872,6 +897,10 @@ export class SynchService implements vscode.Disposable { public getWebSocket(): ViewerEditWSClient | undefined { return this.websocket; } + + public isConnected(): boolean { + return this.websocket?.isConnected() ?? false; + } //#endregion //==================================================================== @@ -954,6 +983,58 @@ export class SynchService implements vscode.Disposable { } //#endregion + public async connectToViewer(params: { port?: number; object_id?: string; script_id?: string }): Promise { + this.pendingLaunchObjectId = params.object_id; + this.pendingLaunchScriptId = params.script_id; + + if (this.websocket?.isConnected()) { + // Already connected — act on params immediately + await this.handleLaunchParams(); + return; + } + + await this.setupConnection(params.port); + // handleLaunchParams is called from onHandshakeOk + } + + private async handleLaunchParams(): Promise { + const objectId = this.pendingLaunchObjectId; + const scriptId = this.pendingLaunchScriptId; + this.pendingLaunchObjectId = undefined; + this.pendingLaunchScriptId = undefined; + + if (objectId && this.websocket?.isConnected()) { + const result = await this.websocket.requestObject({ object_id: objectId }); + if (!result.success) { + showWarningMessage(`Failed to request object: ${result.message ?? "unknown error"}`); + } + } + + if (scriptId && this.websocket?.isConnected()) { + await this.openScriptById(scriptId); + } + } + + private async openScriptById(scriptId: string): Promise { + if (!this.websocket) { return; } + const list = await this.websocket.getScriptList(); + if (!list.success) { return; } + + try { + const files = await fs.promises.readdir(list.temp_dir); + const match = files.find(f => f.includes(scriptId)); + if (match) { + const tempPath = path.join(list.temp_dir, match); + await vscode.window.showTextDocument(vscode.Uri.file(tempPath)); + // onOpenTextDocument fires and handles the normal subscribe + sync flow + } else { + showWarningMessage(`Script ${scriptId} not found in viewer temp directory`); + } + } catch { + showWarningMessage(`Could not open script ${scriptId} from temp directory`); + } + } + public activate(): void { this.deactivate(); this.initialize(); diff --git a/src/utils.ts b/src/utils.ts index bea5205..e346133 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -36,6 +36,17 @@ export function logInfo(message: string): void { channel.appendLine(`[${timestamp}] INFO: ${message}`); } +/** + * Log a debug message to the output channel (only when debug logging is enabled) + */ +export function logDebug(message: string): void { + // TODO: Check a debug setting to conditionally log + // For now, always log debug messages + const channel = getOutputChannel(); + const timestamp = new Date().toISOString(); + channel.appendLine(`[${timestamp}] DEBUG: ${message}`); +} + /** * Log a warning message to the output channel */ diff --git a/src/viewereditwsclient.ts b/src/viewereditwsclient.ts index 90722b2..9748d2c 100644 --- a/src/viewereditwsclient.ts +++ b/src/viewereditwsclient.ts @@ -7,6 +7,23 @@ import { JSONRPCClient } from "./websockclient"; import { ConfigService } from "./configservice"; import { ConfigKey } from "./interfaces/configinterface"; import { showStatusMessage } from "./utils"; +import { + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, + ObjectContentGetParams, + ObjectContentGetResponse, + ObjectContentSaveParams, + ObjectContentSaveResponse, + ObjectItemCreateParams, + ObjectItemCreateResponse, + ObjectItemDeleteParams, + ObjectItemDeleteResponse, + ObjectScriptSetRunningParams, + ObjectScriptSetRunningResponse, + ObjectRequestParams, + ObjectRequestResponse, +} from "./vscode/objectcontentinterfaces"; //#region Message Formats @@ -67,6 +84,12 @@ export interface SyntaxCacheList { success: boolean; } +export interface ScriptList { + temp_dir: string; + script_ids: string[]; + success: boolean; +} + export interface SyntaxCacheGetRequest { filename: string; as_json?: boolean; @@ -124,6 +147,9 @@ export interface WebSocketHandlers { onRuntimeDebug?: (message: RuntimeDebug) => void; onRuntimeError?: (message: RuntimeError) => void; onConnectionClosed?: () => void; + onObjectPublish?: (message: ObjectPublishMessage) => void; + onObjectUnpublish?: (message: ObjectUnpublishMessage) => void; + onObjectUpdate?: (message: ObjectUpdateMessage) => void; } /** @@ -186,6 +212,9 @@ export class ViewerEditWSClient extends JSONRPCClient { this.on("script.compiled", this.handlers.onCompilationResult); this.on("runtime.debug", this.handlers.onRuntimeDebug); this.on("runtime.error", this.handlers.onRuntimeError); + this.on("object.publish", this.handlers.onObjectPublish); + this.on("object.unpublish", this.handlers.onObjectUnpublish); + this.on("object.update", this.handlers.onObjectUpdate); // Setup connection close handler this.setupConnectionCloseHandler(); @@ -226,6 +255,38 @@ export class ViewerEditWSClient extends JSONRPCClient { } } + // ============================================ + // Object Content Calls (Extension → Viewer) + // ============================================ + + public getObjectContent(params: ObjectContentGetParams): Promise { + return this.call("object.content.get", params); + } + + public saveObjectContent(params: ObjectContentSaveParams): Promise { + return this.call("object.content.save", params); + } + + public createObjectItem(params: ObjectItemCreateParams): Promise { + return this.call("object.item.create", params); + } + + public deleteObjectItem(params: ObjectItemDeleteParams): Promise { + return this.call("object.item.delete", params); + } + + public setScriptRunning(params: ObjectScriptSetRunningParams): Promise { + return this.call("object.script.set_running", params); + } + + public requestObject(params: ObjectRequestParams): Promise { + return this.call("object.request", params); + } + + public getScriptList(): Promise { + return this.call("script.list", {}); + } + private setupConnectionCloseHandler(): void { // Instead of overriding dispose, use a periodic check for connection state const checkConnectionInterval = setInterval(() => { From 83ffd934264763b2a912d923f7d5faee6aec2593 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Tue, 19 May 2026 08:57:10 -0700 Subject: [PATCH 4/9] [WIP] Act as a consumer for objects published from the viewer. Treat them as virtual file systems, so that they appear as folders in the explorer tab. Not yet in a stable state. --- doc/Message_Interfaces.md | 144 ++++--- src/synchservice.ts | 24 +- src/vscode/ObjectContentDecorator.ts | 86 ++++ src/vscode/objectcontentinterfaces.ts | 238 +++++++++++ src/vscode/objectcontentprovider.ts | 587 ++++++++++++++++++++++++++ src/vscode/objectcontentservice.ts | 332 +++++++++++++++ src/websockclient.ts | 38 +- 7 files changed, 1382 insertions(+), 67 deletions(-) create mode 100644 src/vscode/ObjectContentDecorator.ts create mode 100644 src/vscode/objectcontentinterfaces.ts create mode 100644 src/vscode/objectcontentprovider.ts create mode 100644 src/vscode/objectcontentservice.ts diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 45b725d..022378a 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -4,45 +4,45 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents -- [Usage Flow](#usage-flow) -- [VS Code Launch URI](#vs-code-launch-uri) -- [JSON-RPC Method Summary](#json-rpc-method-summary) -- [Session Management Interfaces](#session-management-interfaces) - - [SessionHandshake](#sessionhandshake) - - [SessionHandshakeResponse](#sessionhandshakeresponse) - - [Session OK](#session-ok) - - [SessionDisconnect](#sessiondisconnect) -- [Language and Syntax Interfaces](#language-and-syntax-interfaces) - - [SyntaxChange](#syntaxchange) - - [Language Syntax ID Request](#language-syntax-id-request) - - [Language Syntax Request](#language-syntax-request) - - [Language Syntax Cache List](#language-syntax-cache-list) - - [Language Syntax Cache Get](#language-syntax-cache-get) -- [Script Subscription Interfaces](#script-subscription-interfaces) - - [ScriptSubscribe](#scriptsubscribe) - - [ScriptSubscribeResponse](#scriptsubscriberesponse) - - [ScriptUnsubscribe](#scriptunsubscribe) - - [ScriptList](#scriptlist) -- [Compilation Interfaces](#compilation-interfaces) - - [CompilationError](#compilationerror) - - [CompilationResult](#compilationresult) -- [Runtime Event Interfaces](#runtime-event-interfaces) - - [RuntimeDebug](#runtimedebug) - - [RuntimeError](#runtimeerror) -- [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - - [WebSocketHandlers](#websockethandlers) - - [ClientInfo](#clientinfo) -- [Object Content Interfaces](#object-content-interfaces) - - [Core Data Types](#core-data-types) - - [ObjectPublish](#objectpublish) - - [ObjectUnpublish](#objectunpublish) - - [ObjectUpdate](#objectupdate) - - [ObjectContentGet](#objectcontentget) - - [ObjectContentSave](#objectcontentsave) - - [ObjectItemCreate](#objectitemcreate) - - [ObjectItemDelete](#objectitemdelete) - - [ObjectScriptSetRunning](#objectscriptsetrunning) - - [ObjectRequest](#objectrequest) +- [Usage Flow](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#usage-flow) +- [VS Code Launch URI](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#vs-code-launch-uri) +- [JSON-RPC Method Summary](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#json-rpc-method-summary) +- [Session Management Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-management-interfaces) + - [SessionHandshake](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshake) + - [SessionHandshakeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshakeresponse) + - [Session OK](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-ok) + - [SessionDisconnect](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessiondisconnect) +- [Language and Syntax Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-and-syntax-interfaces) + - [SyntaxChange](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#syntaxchange) + - [Language Syntax ID Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-id-request) + - [Language Syntax Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-request) + - [Language Syntax Cache List](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-list) + - [Language Syntax Cache Get](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-get) +- [Script Subscription Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#script-subscription-interfaces) + - [ScriptSubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscribe) + - [ScriptSubscribeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscriberesponse) + - [ScriptUnsubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptunsubscribe) + - [ScriptList](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptlist) +- [Compilation Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilation-interfaces) + - [CompilationError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationerror) + - [CompilationResult](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationresult) +- [Runtime Event Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtime-event-interfaces) + - [RuntimeDebug](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimedebug) + - [RuntimeError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimeerror) +- [Handler and Configuration Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#handler-and-configuration-interfaces) + - [WebSocketHandlers](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#websockethandlers) + - [ClientInfo](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#clientinfo) +- [Object Content Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#object-content-interfaces) + - [Core Data Types](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#core-data-types) + - [ObjectPublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectpublish) + - [ObjectUnpublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectunpublish) + - [ObjectUpdate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectupdate) + - [ObjectContentGet](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentget) + - [ObjectContentSave](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentsave) + - [ObjectItemCreate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemcreate) + - [ObjectItemDelete](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemdelete) + - [ObjectScriptSetRunning](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectscriptsetrunning) + - [ObjectRequest](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectrequest) ## Usage Flow @@ -378,14 +378,14 @@ interface SyntaxCacheList { | -------------------------------- | ---------------------------------------------------- | | `builtins.txt` | LSL built-in keyword list in plain text format | | `lsl_definitions.yaml` | LSL language definitions in YAML format | -| `lsl_keywords.xml` | LSL keyword definitions in LLSD XML format | +| `lsl_keywords.xml` | LSL keyword definitions in LLSD XML format. Used by the viewer's script editor | | `lsl_keywords_pretty.xml` | LSL keyword definitions in formatted LLSD XML format | -| `slua_default.d.luau` | Luau type definition file for editor tooling | -| `slua_default.docs.json` | Luau documentation data in JSON format | +| `secondlife.d.luau` | Luau type definition file. Used by luau-lsp | +| `secondlife.docs.json` | Luau documentation data in JSON format. Used by luau-lsp | | `slua_definitions.yaml` | Luau language definitions in YAML format | -| `slua_keywords.xml` | Luau keyword definitions in LLSD XML format | -| `slua_keywords_pretty.xml` | Luau keyword definitions in formatted LLSD XML format | -| `slua_selene.yml` | Luau Selene linter configuration in YAML format | +| `lua_keywords.xml` | Luau keyword definitions in LLSD XML format. Used by the viewer's script editor | +| `lua_keywords_pretty.xml` | Luau keyword definitions in formatted LLSD XML format | +| `secondlife_selene.yml` | Luau Selene linter configuration in YAML format | Not all files may be present in every cache — the actual list returned by `language.syntax.cache` reflects only what is available on the viewer's local filesystem at the time of the request. @@ -683,7 +683,7 @@ These interfaces support publishing in-world object inventories (scripts and not ```typescript type InventoryItemType = "script" | "notecard"; -type ScriptVM = "lso" | "mono" | "luau"; +type ScriptVM = "lsl2" | "mono" | "luau"; /** Permission mask fields. Only owner and next_owner are transmitted. */ interface ItemPermissions { @@ -838,10 +838,10 @@ interface ObjectContentGetParams { } interface ObjectContentGetResponse { + success: boolean; prim_id: string; item_id: string; - content: string; - encoding?: "utf-8" | "base64"; + content: string; // Raw text content (UTF-8). Notecard envelope is unwrapped automatically. } ``` @@ -849,8 +849,8 @@ interface ObjectContentGetResponse { - `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID without knowing the root object_id. - `item_id`: Inventory item UUID. -- `content`: The raw text content of the item. -- `encoding` (optional): Encoding used for `content`. Defaults to `"utf-8"` if absent. +- `success`: `true` on success. +- `content`: The raw text content of the item. For notecards, the `Linden text version 2` envelope is stripped — only the body text is returned. --- @@ -865,17 +865,28 @@ interface ObjectContentSaveParams { prim_id: string; item_id: string; content: string; + vm?: "mono" | "lsl2" | "luau"; } interface ObjectContentSaveResponse { success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; + errors?: string[]; message?: string; } ``` **Fields:** -- `success`: Whether the save (and compilation, if applicable) succeeded. +- `prim_id`: UUID of the prim that owns the saved item. +- `item_id`: UUID of the saved inventory item. +- `content`: Raw script/notecard source text to store. +- `vm` (optional): Scripts only compile target. Accepted values are `"mono"`, `"lsl2"`, `"luau"`. When `"luau"` is specified for an LSL script (as opposed to a native Luau script), the viewer automatically selects the correct LSL-on-Luau compile path. If omitted, inferred from item metadata or content analysis. +- `success`: Whether the upload/save operation succeeded. +- `compiled` (optional): Scripts only. `true` when compilation succeeded, `false` when source saved but compile failed. +- `errors` (optional): Scripts only. Compiler diagnostics when `compiled` is `false`. - `message` (optional): Error description on failure. --- @@ -884,25 +895,33 @@ interface ObjectContentSaveResponse { **JSON-RPC Method:** `object.item.create` (call from extension to viewer) -Creates a new script or notecard in a prim's inventory. +Creates a new script in a prim's inventory. The call is asynchronous — the viewer sends +`RezScript` to the simulator and waits for the inventory-changed callback before returning +the created item's details. The simulator may rename the item if a duplicate name exists. + +Notecard creation is not yet supported and will return an error. ```typescript interface ObjectItemCreateParams { - prim_id: string; + prim_id: string; // UUID of the prim to create the item in name: string; // Pure SL inventory name — no file extension - type: InventoryItemType; // "script" | "notecard" - vm?: ScriptVM; // Scripts only: target VM. Defaults to viewer default if absent. - content?: string; // Initial content. Empty item created if absent. + type: InventoryItemType; // "script" ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "lsl" } -interface ObjectItemCreateResponse { - success: boolean; - item_id?: string; // UUID of the created item (on success) - item?: ObjectInventoryItem; // Full item metadata including assigned subtype (on success) - message?: string; // Error description (on failure) +// On success, returns an ObjectInventoryItem with prim_id: +interface ObjectItemCreateResponse extends ObjectInventoryItem { + prim_id: string; // Echoed prim UUID } ``` +**Notes:** +- The response matches the `ObjectInventoryItem` structure (same fields as items in + `object.publish` and `object.update` notifications). +- The `name` in the response may differ from the request if the simulator renamed it. +- An `object.update` notification will also fire for the prim (since inventory changed). +- Timeout: 30 seconds. Returns a JSON-RPC internal error if the simulator does not respond. + --- ### ObjectItemDelete @@ -919,7 +938,8 @@ interface ObjectItemDeleteParams { interface ObjectItemDeleteResponse { success: boolean; - message?: string; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request } ``` diff --git a/src/synchservice.ts b/src/synchservice.ts index df80ade..e5270cd 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -30,6 +30,7 @@ import { showInfoMessage, showStatusMessage, showWarningMessage, + logDebug, logInfo, VSCodeHost, closeTextDocument, @@ -361,9 +362,18 @@ export class SynchService implements vscode.Disposable { onCompilationResult: (message: CompilationResult): any => this.onCompilationResult(message), onRuntimeDebug: (message: RuntimeDebug): any => this.onRuntimeDebug(message), onRuntimeError: (message: RuntimeError): any => this.onRuntimeError(message), - onObjectPublish: (msg: ObjectPublishMessage): any => ObjectContentService.getInstance().handlePublish(msg), - onObjectUnpublish: (msg: ObjectUnpublishMessage): any => ObjectContentService.getInstance().handleUnpublish(msg), - onObjectUpdate: (msg: ObjectUpdateMessage): any => ObjectContentService.getInstance().handleUpdate(msg), + onObjectPublish: (msg: ObjectPublishMessage): any => { + logDebug(`[object.publish] object_id=${msg.object.object_id}`); + ObjectContentService.getInstance().handlePublish(msg); + }, + onObjectUnpublish: (msg: ObjectUnpublishMessage): any => { + logDebug(`[object.unpublish] object_id=${msg.object_id}`); + ObjectContentService.getInstance().handleUnpublish(msg); + }, + onObjectUpdate: (msg: ObjectUpdateMessage): any => { + logDebug(`[object.update] object_id=${msg.object_id}`); + ObjectContentService.getInstance().handleUpdate(msg); + }, }; if (this.websocket && this.websocket.isConnected()) { @@ -1005,8 +1015,14 @@ export class SynchService implements vscode.Disposable { if (objectId && this.websocket?.isConnected()) { const result = await this.websocket.requestObject({ object_id: objectId }); - if (!result.success) { + if (result.object) { + logDebug(`[object.request] response contained object_id=${result.object.object_id}`); + ObjectContentService.getInstance().handlePublish({ object: result.object }); + } else if (result.success === false) { showWarningMessage(`Failed to request object: ${result.message ?? "unknown error"}`); + } else { + // Keep this visible while we support mixed viewer versions. + logDebug("[object.request] response contained no object payload; waiting for object.publish notification"); } } diff --git a/src/vscode/ObjectContentDecorator.ts b/src/vscode/ObjectContentDecorator.ts new file mode 100644 index 0000000..c23585a --- /dev/null +++ b/src/vscode/ObjectContentDecorator.ts @@ -0,0 +1,86 @@ +/** + * @file ObjectContentDecorator.ts + * File decoration provider for sl:// virtual filesystem entries. + * Shows visual indicators for connection state, script running state, etc. + * Copyright (C) 2025, Linden Research, Inc. + */ +import { + FileDecorationProvider, + Uri, + FileDecoration, + EventEmitter, + ProviderResult, + CancellationToken, + ThemeColor, + Disposable, +} from "vscode"; +import { SL_SCHEME } from "./objectcontentprovider"; +import { logDebug } from "../utils"; + +/** + * Provides file decorations for sl:// URIs based on connection state. + * When disconnected from the viewer, shows a red badge and tooltip. + */ +export class ObjectContentDecorator implements FileDecorationProvider, Disposable { + private _onDidChangeFileDecorations = new EventEmitter(); + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event; + + private isConnected: boolean = false; + private disposables: Disposable[] = []; + + constructor( + private readonly getConnectionState: () => boolean, + onConnectionChange: (listener: (connected: boolean) => void) => Disposable, + ) { + this.isConnected = getConnectionState(); + logDebug(`[ObjectContentDecorator] Initial connection state: ${this.isConnected}`); + + // Listen for connection state changes + const subscription = onConnectionChange((connected) => { + logDebug(`[ObjectContentDecorator] Connection state changed: ${connected}`); + this.isConnected = connected; + // Refresh all sl:// decorations (deferred to ensure processing when not focused) + setTimeout(() => { + this._onDidChangeFileDecorations.fire(undefined); + }, 0); + }); + this.disposables.push( + subscription, + this._onDidChangeFileDecorations, + ); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } + + provideFileDecoration(uri: Uri, _token: CancellationToken): ProviderResult { + // Only decorate sl:// URIs + if (uri.scheme !== SL_SCHEME) { + return undefined; + } + + // When disconnected, show red badge with warning + if (!this.isConnected) { + return { + badge: "⚠", + tooltip: "Not connected to Second Life viewer", + color: new ThemeColor("errorForeground"), + }; + } + + // Connected state - no special decoration for now + // Future: could show script running state, dirty state, etc. + return undefined; + } + + /** + * Manually trigger a refresh of decorations for specific URIs or all sl:// URIs. + */ + public refresh(uri?: Uri | Uri[]): void { + this._onDidChangeFileDecorations.fire(uri); + } +} diff --git a/src/vscode/objectcontentinterfaces.ts b/src/vscode/objectcontentinterfaces.ts new file mode 100644 index 0000000..d4ee775 --- /dev/null +++ b/src/vscode/objectcontentinterfaces.ts @@ -0,0 +1,238 @@ +/** + * @file objectcontentinterfaces.ts + * Interfaces for object content publishing feature + * Copyright (C) 2025, Linden Research, Inc. + */ + +// ============================================ +// Core Data Types +// ============================================ + +/** + * Types of inventory items supported by the object content provider. + * Only scripts and notecards are supported; other item types are not exposed. + */ +export type InventoryItemType = "script" | "notecard"; + +/** Script virtual machine / compiler target */ +export type ScriptVM = "lsl2" | "mono" | "luau"; + +/** Save-time compile target accepted by object.content.save */ +export type ObjectContentSaveVM = "mono" | "lsl2" | "luau"; + +/** + * Permission masks from viewer's LLPermissions. + * Only owner and next_owner masks are transmitted. + * Bit flags: PERM_MODIFY=0x4000, PERM_COPY=0x8000, PERM_TRANSFER=0x2000 + */ +export interface ItemPermissions { + owner: number; // Current owner's permission mask + next_owner: number; // Applied on transfer +} + +/** + * Inventory item within an object or linked prim. + * Only scripts and notecards are supported; other item types are not exposed. + * Note: asset_id is intentionally not transmitted for security. + */ +export interface ObjectInventoryItem { + item_id: string; // Inventory item UUID (unique within container) + name: string; // Display name + description?: string; // Item description + type: InventoryItemType; + /** Scripts only: script language subtype from viewer's II_FLAGS_SUBTYPE_MASK (0=LSL, 1=Luau) */ + subtype?: number; + /** Scripts only: which VM the script targets/was compiled for (from script metadata) */ + vm?: ScriptVM; + /** For scripts only: whether the script is currently running */ + running?: boolean; + permissions?: ItemPermissions; + creator_id?: string; // Creator UUID +} + +/** + * A linked prim within a linkset + */ +export interface LinkedObject { + link_id: string; // UUID of the linked prim + link_number: number; // Link number (2+ = children; root is 1) + link_name: string; // Display name of the linked prim + link_description?: string; + inventory: ObjectInventoryItem[]; +} + +/** + * Permission masks for the object itself + */ +export interface ObjectPermissions { + owner: number; + next_owner: number; +} + +/** + * Published object container (root of a linkset) + */ +export interface PublishedObject { + object_id: string; // UUID of the root prim + object_name: string; // Display name of root prim + object_description?: string; + region?: string; // Region where object exists + owner_id?: string; // Owner UUID + permissions?: ObjectPermissions; + inventory: ObjectInventoryItem[]; // Root prim inventory (scripts and notecards only) + linked_objects?: LinkedObject[]; // Child prims in linkset +} + +// ============================================ +// WebSocket Message Interfaces +// ============================================ + +/** object.publish — Viewer → Extension (notification) */ +export interface ObjectPublishMessage { + object: PublishedObject; +} + +/** object.unpublish — Viewer → Extension (notification) */ +export interface ObjectUnpublishMessage { + object_id: string; + reason?: string; +} + +/** Delta changes for inventory items */ +export interface InventoryChanges { + added?: ObjectInventoryItem[]; + removed?: string[]; // item_ids + modified?: ObjectInventoryItem[]; // metadata changes + content_changed?: string[]; // item_ids (invalidates cache) + running_changed?: { item_id: string; running: boolean }[]; +} + +/** Delta changes for linked objects */ +export interface LinkedObjectChanges { + added?: LinkedObject[]; + removed?: string[]; // link_ids + modified?: { + link_id: string; + link_name?: string; + inventory?: InventoryChanges; + }[]; +} + +/** + * object.update — Viewer → Extension (notification) + * Supports full replacement or delta-based updates. + * If `changes` is present it takes precedence over full replacement fields. + */ +export interface ObjectUpdateMessage { + object_id: string; + object_name?: string; + // Full replacement + inventory?: ObjectInventoryItem[]; + linked_objects?: LinkedObject[]; + // Delta + changes?: { + inventory?: InventoryChanges; + linked_objects?: LinkedObjectChanges; + }; +} + +/** object.content.get — Extension → Viewer (call) */ +export interface ObjectContentGetParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; +} + +/** object.content.get response */ +export interface ObjectContentGetResponse { + prim_id: string; + item_id: string; + content: string; + encoding?: "utf-8" | "base64"; +} + +/** object.content.save — Extension → Viewer (call) */ +export interface ObjectContentSaveParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; + content: string; + vm?: ObjectContentSaveVM; // Scripts only: explicit compile target +} + +/** object.content.save response */ +export interface ObjectContentSaveResponse { + success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; // Scripts only: true if compilation succeeded + errors?: string[]; // Scripts only: compiler diagnostics when compiled is false + message?: string; +} + +/** object.item.create — Extension → Viewer (call) */ +export interface ObjectItemCreateParams { + prim_id: string; // UUID of any prim (root or child) + name: string; // Item name (no extension — pure SL inventory name) + type: InventoryItemType; // "script" for current create flow ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "mono" | "lsl2" +} + +/** object.item.create response */ +export interface ObjectItemCreateResponse extends ObjectInventoryItem { + prim_id: string; // Echoed prim UUID +} + +/** object.item.delete — Extension → Viewer (call) */ +export interface ObjectItemDeleteParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; +} + +/** object.item.delete response */ +export interface ObjectItemDeleteResponse { + success: boolean; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request +} + +/** object.script.set_running — Extension → Viewer (call) */ +export interface ObjectScriptSetRunningParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; + running: boolean; // true = start, false = stop +} + +/** object.script.set_running response */ +export interface ObjectScriptSetRunningResponse { + success: boolean; + message?: string; +} + +/** object.request — Extension → Viewer (call) */ +export interface ObjectRequestParams { + object_id: string; // UUID of the root prim to request publishing for +} + +/** object.request response */ +export interface ObjectRequestResponse { + object?: PublishedObject; // Primary response payload for requested object + success?: boolean; // Legacy compatibility for older viewers + message?: string; // reason on failure (e.g. "object not found", "permission denied") +} + +// ============================================ +// Internal Service Types (not transmitted) +// ============================================ + +/** Cached content for an inventory item */ +export interface CachedItemContent { + content: Uint8Array; + mtime: number; + dirty: boolean; +} + +/** Internal representation of a published object with content cache */ +export interface ObjectEntry { + object: PublishedObject; + contentCache: Map; // keyed by item_id + publishedAt: number; +} diff --git a/src/vscode/objectcontentprovider.ts b/src/vscode/objectcontentprovider.ts new file mode 100644 index 0000000..aaddf2a --- /dev/null +++ b/src/vscode/objectcontentprovider.ts @@ -0,0 +1,587 @@ +/** + * @file objectcontentprovider.ts + * VS Code FileSystemProvider for the sl:// virtual filesystem. + * Presents published Second Life in-world objects as browseable directories. + * Copyright (C) 2025, Linden Research, Inc. + */ +import * as vscode from "vscode"; +import { ObjectContentService } from "./objectcontentservice"; +import { ViewerEditWSClient } from "../viewereditwsclient"; +import { + InventoryItemType, + ObjectContentSaveVM, + ObjectInventoryItem, + ScriptVM, +} from "./objectcontentinterfaces"; + +// ============================================ +// Constants +// ============================================ + +export const SL_SCHEME = "sl"; +export const SL_AUTHORITY = "objects"; + +/** PERM_MODIFY bit from viewer LLPermissions */ +const PERM_MODIFY = 0x4000; + +// JSON-RPC error codes used by the viewer +const JSONRPC_INVALID_PARAMS = -32602; +const JSONRPC_FORBIDDEN = -32003; +const JSONRPC_TIMEOUT = -32001; +const JSONRPC_INTERNAL_ERROR = -32603; + +/** + * Extract JSON-RPC error code from error message. + * The websocket client formats errors as "JSON-RPC Error {code}: {message}" + */ +function extractJsonRpcErrorCode(error: Error): number | undefined { + const match = error.message.match(/^JSON-RPC Error (-?\d+):/); + return match ? parseInt(match[1], 10) : undefined; +} + +/** + * Map JSON-RPC error to appropriate FileSystemError. + */ +function mapRpcErrorToFileSystemError(error: unknown, uri: vscode.Uri): Error { + if (!(error instanceof Error)) { + return vscode.FileSystemError.Unavailable(uri); + } + + const code = extractJsonRpcErrorCode(error); + switch (code) { + case JSONRPC_INVALID_PARAMS: + // Prim not found, item not found, or invalid item type + return vscode.FileSystemError.FileNotFound(uri); + case JSONRPC_FORBIDDEN: + // Object not published or insufficient permissions + return vscode.FileSystemError.NoPermissions(uri); + case JSONRPC_TIMEOUT: + // Simulator didn't respond in time + return vscode.FileSystemError.Unavailable(`Request timed out: ${uri}`); + case JSONRPC_INTERNAL_ERROR: + // Asset cache issue or other internal error + return vscode.FileSystemError.Unavailable(error.message); + default: + // Pass through the original error message + return vscode.FileSystemError.Unavailable(error.message); + } +} + +// ============================================ +// URI Helpers +// ============================================ + +interface ParsedObjectUri { + /** Root prim UUID (always present) */ + root_id: string; + /** Child prim UUID if path has 2+ segments, otherwise undefined */ + link_id?: string; + /** Item UUID if this is a file URI, otherwise undefined */ + item_id?: string; + /** Unresolved leaf filename for create flows */ + pending_name?: string; + /** Whether this URI refers to a directory */ + isDirectory: boolean; +} + +interface ParseUriOptions { + allowMissingLeaf?: boolean; +} + +/** Check if a string looks like a UUID */ +function isUUID(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + +/** + * Find an item in an inventory list by its display name. + * Returns the item_id if found, undefined otherwise. + */ +function findItemByDisplayName( + inventory: ObjectInventoryItem[] | undefined, + filename: string +): string | undefined { + if (!inventory) return undefined; + const item = inventory.find((i) => displayName(i) === filename); + return item?.item_id; +} + +/** + * Parse an sl://objects/... URI into its components. + * + * URI shapes: + * sl://objects/{root_id} → root directory + * sl://objects/{root_id}/{seg} → file in root (item_id or display name) + * OR child prim directory (link_id = seg) + * sl://objects/{root_id}/{link_id}/{seg} → file in child prim (item_id or display name) + * + * For file URIs, the segment can be either: + * - A UUID (item_id directly) — used by API calls + * - A display name (e.g., "Hello World.lsl") — used by Explorer + * + * Directories are distinguished from files by checking the object tree. + */ +function parseUri( + uri: vscode.Uri, + service: ObjectContentService, + options?: ParseUriOptions +): ParsedObjectUri { + // Strip leading slash and split + const parts = uri.path.replace(/^\//, "").split("/").filter((p) => p.length > 0); + + if (parts.length === 0) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const root_id = parts[0]; + const entry = service.getObject(root_id); + + if (parts.length === 1) { + return { root_id, isDirectory: true }; + } + + const seg1 = parts[1]; + + if (parts.length === 2) { + // Is seg1 a linked prim (directory) or an item (file)? + const isLinkedPrim = + entry?.object.linked_objects?.some((lo) => lo.link_id === seg1) ?? false; + + if (isLinkedPrim) { + return { root_id, link_id: seg1, isDirectory: true }; + } + + // seg1 is either a UUID or a display name + let item_id: string | undefined; + if (isUUID(seg1)) { + item_id = seg1; + } else { + item_id = findItemByDisplayName(entry?.object.inventory, seg1); + } + if (!item_id) { + if (options?.allowMissingLeaf) { + return { root_id, pending_name: seg1, isDirectory: false }; + } + throw vscode.FileSystemError.FileNotFound(uri); + } + return { root_id, item_id, isDirectory: false }; + } + + if (parts.length === 3) { + const link_id = parts[1]; + const seg2 = parts[2]; + + // seg2 is either a UUID or a display name + let item_id: string | undefined; + if (isUUID(seg2)) { + item_id = seg2; + } else { + const linkedObj = entry?.object.linked_objects?.find((lo) => lo.link_id === link_id); + item_id = findItemByDisplayName(linkedObj?.inventory, seg2); + } + if (!item_id) { + if (options?.allowMissingLeaf) { + return { root_id, link_id, pending_name: seg2, isDirectory: false }; + } + throw vscode.FileSystemError.FileNotFound(uri); + } + return { root_id, link_id, item_id, isDirectory: false }; + } + + throw vscode.FileSystemError.FileNotFound(uri); +} + +/** Build a URI for a root prim directory */ +export function rootUri(root_id: string): vscode.Uri { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}` }); +} + +/** Build a URI for a linked prim directory */ +export function linkedPrimUri(root_id: string, link_id: string): vscode.Uri { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${link_id}` }); +} + +/** Build a URI for a file (item in root or linked prim) */ +export function itemUri(root_id: string, prim_id: string, item_id: string): vscode.Uri { + if (prim_id === root_id) { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${item_id}` }); + } + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${prim_id}/${item_id}` }); +} + +// ============================================ +// Display Name Helpers +// ============================================ + +/** Map item subtype to the appropriate file extension for display */ +function extensionForItem(item: ObjectInventoryItem): string { + if (item.type === "notecard") return ".txt"; + return item.subtype === 1 ? ".luau" : ".lsl"; +} + +/** Returns the display filename (name + synthetic extension) */ +function displayName(item: ObjectInventoryItem): string { + return item.name + extensionForItem(item); +} + +/** + * Derive type and vm from a synthetic display extension. + * Used when creating new items from a filename the user typed. + */ +function typeAndVmFromExtension(ext: string): { type: InventoryItemType; vm: ScriptVM } | undefined { + switch (ext.toLowerCase()) { + case ".luau": return { type: "script", vm: "luau" }; + case ".lsl": return { type: "script", vm: "lsl2" }; + default: return undefined; + } +} + +/** + * Derive object.content.save vm from current script metadata. + * Save API accepts: mono, lsl2, luau. + */ +function saveVmForItem(item: ObjectInventoryItem): ObjectContentSaveVM | undefined { + if (item.type !== "script") { + return undefined; + } + + // Prefer explicit VM from metadata. + if (item.vm === "luau") { + return "luau"; + } + + if (item.vm === "lsl2") { + return "lsl2"; + } + + if (item.vm === "mono") { + return "mono"; + } + + // Compatibility fallback when VM metadata is absent. + if (item.subtype === 1) { + return "luau"; + } + + return "mono"; +} + +/** Strip the synthetic display extension to recover the raw SL inventory name */ +function stripExtension(filename: string): { name: string; ext: string } { + const dot = filename.lastIndexOf("."); + if (dot === -1) return { name: filename, ext: "" }; + return { name: filename.slice(0, dot), ext: filename.slice(dot) }; +} + +// ============================================ +// FileSystemProvider +// ============================================ + +export class ObjectContentProvider implements vscode.FileSystemProvider, vscode.Disposable { + private _onDidChangeFile = new vscode.EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly service: ObjectContentService, + private readonly getClient: () => ViewerEditWSClient | undefined, + ) { + // Forward content invalidations to VS Code as Changed events + this.disposables.push( + service.onDidChangeContent(({ object_id, prim_id, item_id }) => { + const uri = itemUri(object_id, prim_id, item_id); + // Defer to ensure VS Code processes even when not focused + setTimeout(() => { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + }, 0); + }), + + // Forward tree changes (added/removed objects) as directory changes + service.onDidChangeObjects(({ type, object_id }) => { + const uri = rootUri(object_id); + // Defer to ensure VS Code processes even when not focused + setTimeout(() => { + if (type === "added") { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Created, uri }]); + } else if (type === "removed") { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Deleted, uri }]); + } else { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + }, 0); + }), + + this._onDidChangeFile, + ); + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + this.disposables = []; + } + + // ============================================ + // FileSystemProvider — required methods + // ============================================ + + watch(_uri: vscode.Uri): vscode.Disposable { + // Change notifications are pushed from the service; no polling needed. + return new vscode.Disposable(() => { }); + } + + stat(uri: vscode.Uri): vscode.FileStat { + const parsed = parseUri(uri, this.service); + + if (parsed.isDirectory) { + return { + type: vscode.FileType.Directory, + ctime: 0, + mtime: 0, + size: 0, + }; + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + const item = this.service.getItem(root_id, prim_id, item_id!); + if (!item) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const cached = this.service.getCachedContent(root_id, item_id!); + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + + return { + type: vscode.FileType.File, + ctime: this.service.getObject(root_id)?.publishedAt ?? 0, + mtime: cached?.mtime ?? 0, + size: cached?.content.byteLength ?? 0, + permissions: canModify ? undefined : vscode.FilePermission.Readonly, + }; + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + const parsed = parseUri(uri, this.service); + if (!parsed.isDirectory) { + throw vscode.FileSystemError.FileNotADirectory(uri); + } + + const { root_id, link_id } = parsed; + const entry = this.service.getObject(root_id); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const results: [string, vscode.FileType][] = []; + + if (link_id) { + // Listing a child prim directory + const lo = this.service.getLinkedObject(root_id, link_id); + if (!lo) { + throw vscode.FileSystemError.FileNotFound(uri); + } + for (const item of lo.inventory) { + results.push([displayName(item), vscode.FileType.File]); + } + } else { + // Listing the root prim directory + for (const item of entry.object.inventory) { + results.push([displayName(item), vscode.FileType.File]); + } + for (const lo of entry.object.linked_objects ?? []) { + // Use link_id (UUID) as the directory name so URIs remain stable. + // link_name is used as display label via workspace folder naming. + results.push([lo.link_id, vscode.FileType.Directory]); + } + } + + return results; + } + + async readFile(uri: vscode.Uri): Promise { + const parsed = parseUri(uri, this.service); + if (parsed.isDirectory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + + // Return cached content if available + const cached = this.service.getCachedContent(root_id, item_id!); + if (cached) { + return cached.content; + } + + // Fetch from viewer + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const response = await client.getObjectContent({ prim_id, item_id: item_id! }); + const text = response.content ?? ""; + const bytes = Buffer.from(text, "utf-8"); + this.service.cacheContent(root_id, item_id!, bytes); + return bytes; + } catch (error) { + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { create: boolean; overwrite: boolean }, + ): Promise { + const parsed = parseUri(uri, this.service, { allowMissingLeaf: options.create }); + if (parsed.isDirectory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + const { root_id, link_id } = parsed; + const prim_id = link_id ?? root_id; + + // Permission check (only meaningful for existing items) + const entry = this.service.getObject(root_id); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + if (parsed.item_id) { + // item_id known — existing item + const item = this.service.getItem(root_id, prim_id, parsed.item_id); + if (item) { + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + if (!canModify) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const text = Buffer.from(content).toString("utf-8"); + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const vm = saveVmForItem(item); + const result = await client.saveObjectContent({ + prim_id, + item_id: parsed.item_id, + content: text, + vm, + }); + + if (!result.success) { + throw vscode.FileSystemError.Unavailable( + result.message ?? "Save failed" + ); + } + + if (result.compiled === false) { + const diagnostics = (result.errors ?? []).slice(0, 5).join("\n"); + const details = diagnostics.length > 0 + ? `\n${diagnostics}` + : ""; + void vscode.window.showWarningMessage( + `Second Life: Saved, but compilation failed.${details}` + ); + } + + this.service.cacheContent(root_id, parsed.item_id, content); + this.service.markContentSaved(root_id, parsed.item_id); + return; + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + } + + // No existing item — create new + if (!options.create) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const filename = parsed.pending_name + ?? uri.path.replace(/^\//, "").split("/").slice(-1)[0]; + const { name, ext } = stripExtension(filename); + const createParams = typeAndVmFromExtension(ext); + if (!createParams) { + throw vscode.FileSystemError.NoPermissions( + "Only script creation is currently supported (.lsl, .luau)." + ); + } + + const { type, vm } = createParams; + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const result = await client.createObjectItem({ + prim_id, + name, + type, + vm, + }); + + if (!result.item_id) { + throw vscode.FileSystemError.Unavailable("Create failed: missing item_id"); + } + + const response = await client.getObjectContent({ prim_id, item_id: result.item_id }); + const text = response.content ?? ""; + // Cache under the real item_id returned by the viewer + this.service.cacheContent(root_id, result.item_id, Buffer.from(text, "utf-8")); + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + async delete(uri: vscode.Uri, _options: { recursive: boolean }): Promise { + const parsed = parseUri(uri, this.service); + if (parsed.isDirectory) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + + const item = this.service.getItem(root_id, prim_id, item_id!); + if (!item) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + if (!canModify) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const result = await client.deleteObjectItem({ prim_id, item_id: item_id! }); + if (!result.success) { + throw vscode.FileSystemError.Unavailable("Delete failed"); + } + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + // Creating directories (linked prims) and renaming are not supported + createDirectory(_uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(_uri); + } + + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(_oldUri); + } +} diff --git a/src/vscode/objectcontentservice.ts b/src/vscode/objectcontentservice.ts new file mode 100644 index 0000000..c0aa513 --- /dev/null +++ b/src/vscode/objectcontentservice.ts @@ -0,0 +1,332 @@ +/** + * @file objectcontentservice.ts + * Singleton service managing published in-world object content. + * Copyright (C) 2025, Linden Research, Inc. + */ +import * as vscode from "vscode"; +import { + ObjectInventoryItem, + LinkedObject, + ObjectEntry, + CachedItemContent, + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, + InventoryChanges, +} from "./objectcontentinterfaces"; + +// ============================================ +// Event Types +// ============================================ + +/** Fired when objects are added, removed, or have metadata/inventory changes */ +export interface ObjectTreeChangeEvent { + type: "added" | "removed" | "updated"; + object_id: string; +} + +/** Fired when cached content for a specific item is invalidated or needs refresh */ +export interface ObjectContentChangeEvent { + object_id: string; + prim_id: string; + item_id: string; +} + +// ============================================ +// Service +// ============================================ + +export class ObjectContentService implements vscode.Disposable { + private static instance: ObjectContentService | undefined; + + private objects: Map = new Map(); + + private _onDidChangeObjects = new vscode.EventEmitter(); + readonly onDidChangeObjects = this._onDidChangeObjects.event; + + private _onDidChangeContent = new vscode.EventEmitter(); + readonly onDidChangeContent = this._onDidChangeContent.event; + + private disposables: vscode.Disposable[] = []; + + private constructor() { + this.disposables.push(this._onDidChangeObjects, this._onDidChangeContent); + } + + static getInstance(): ObjectContentService { + if (!ObjectContentService.instance) { + ObjectContentService.instance = new ObjectContentService(); + } + return ObjectContentService.instance; + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + if (ObjectContentService.instance === this) { + ObjectContentService.instance = undefined; + } + } + + // ============================================ + // Message Handlers (Viewer → Extension) + // ============================================ + + handlePublish(msg: ObjectPublishMessage): void { + const entry: ObjectEntry = { + object: msg.object, + contentCache: new Map(), + publishedAt: Date.now(), + }; + this.objects.set(msg.object.object_id, entry); + this._onDidChangeObjects.fire({ type: "added", object_id: msg.object.object_id }); + } + + handleUnpublish(msg: ObjectUnpublishMessage): void { + if (this.objects.delete(msg.object_id)) { + this._onDidChangeObjects.fire({ type: "removed", object_id: msg.object_id }); + } + } + + handleUpdate(msg: ObjectUpdateMessage): void { + const entry = this.objects.get(msg.object_id); + if (!entry) { + return; + } + + if (msg.object_name !== undefined) { + entry.object.object_name = msg.object_name; + } + + if (msg.changes) { + // Delta update + if (msg.changes.inventory) { + this._applyInventoryChanges( + entry, + msg.object_id, + msg.object_id, + entry.object.inventory, + msg.changes.inventory + ); + } + if (msg.changes.linked_objects) { + const lc = msg.changes.linked_objects; + if (lc.added) { + entry.object.linked_objects = [ + ...(entry.object.linked_objects ?? []), + ...lc.added, + ]; + } + if (lc.removed) { + const removedSet = new Set(lc.removed); + entry.object.linked_objects = (entry.object.linked_objects ?? []).filter( + (lo) => !removedSet.has(lo.link_id) + ); + // Evict cached content for removed linked prims + for (const link_id of lc.removed) { + this._evictPrimCache(entry, msg.object_id, link_id); + } + } + if (lc.modified) { + for (const mod of lc.modified) { + const lo = (entry.object.linked_objects ?? []).find( + (l) => l.link_id === mod.link_id + ); + if (!lo) continue; + if (mod.link_name !== undefined) { + lo.link_name = mod.link_name; + } + if (mod.inventory) { + this._applyInventoryChanges( + entry, + msg.object_id, + mod.link_id, + lo.inventory, + mod.inventory + ); + } + } + } + } + } else { + // Full replacement + if (msg.inventory !== undefined) { + entry.object.inventory = msg.inventory; + // Evict root prim content cache entirely + this._evictPrimCache(entry, msg.object_id, msg.object_id); + } + if (msg.linked_objects !== undefined) { + // Evict cache for all replaced linked prims + for (const lo of entry.object.linked_objects ?? []) { + this._evictPrimCache(entry, msg.object_id, lo.link_id); + } + entry.object.linked_objects = msg.linked_objects; + } + } + + this._onDidChangeObjects.fire({ type: "updated", object_id: msg.object_id }); + } + + // ============================================ + // Tree Queries + // ============================================ + + getObjects(): readonly ObjectEntry[] { + return Array.from(this.objects.values()); + } + + getObject(object_id: string): ObjectEntry | undefined { + return this.objects.get(object_id); + } + + hasObject(object_id: string): boolean { + return this.objects.has(object_id); + } + + /** + * Returns inventory for any prim in the linkset. + * Pass prim_id === object_id for root prim, or a link_id for a child prim. + */ + getInventory(object_id: string, prim_id: string): ObjectInventoryItem[] | undefined { + const entry = this.objects.get(object_id); + if (!entry) return undefined; + if (prim_id === object_id) { + return entry.object.inventory; + } + return entry.object.linked_objects?.find((lo) => lo.link_id === prim_id)?.inventory; + } + + getItem(object_id: string, prim_id: string, item_id: string): ObjectInventoryItem | undefined { + return this.getInventory(object_id, prim_id)?.find((i) => i.item_id === item_id); + } + + getLinkedObject(object_id: string, link_id: string): LinkedObject | undefined { + return this.objects.get(object_id)?.object.linked_objects?.find( + (lo) => lo.link_id === link_id + ); + } + + // ============================================ + // Content Cache + // ============================================ + + getCachedContent(object_id: string, item_id: string): CachedItemContent | undefined { + return this.objects.get(object_id)?.contentCache.get(item_id); + } + + cacheContent(object_id: string, item_id: string, content: Uint8Array): void { + const entry = this.objects.get(object_id); + if (!entry) return; + const existing = entry.contentCache.get(item_id); + entry.contentCache.set(item_id, { + content, + mtime: Date.now(), + dirty: existing?.dirty ?? false, + }); + } + + markContentDirty(object_id: string, item_id: string): void { + const cached = this.objects.get(object_id)?.contentCache.get(item_id); + if (cached) { + cached.dirty = true; + } + } + + markContentSaved(object_id: string, item_id: string): void { + const cached = this.objects.get(object_id)?.contentCache.get(item_id); + if (cached) { + cached.dirty = false; + } + } + + isContentDirty(object_id: string, item_id: string): boolean { + return this.objects.get(object_id)?.contentCache.get(item_id)?.dirty ?? false; + } + + // ============================================ + // Lifecycle + // ============================================ + + /** Remove all published objects (e.g. on viewer disconnect). */ + clear(): void { + const ids = Array.from(this.objects.keys()); + this.objects.clear(); + for (const object_id of ids) { + this._onDidChangeObjects.fire({ type: "removed", object_id }); + } + } + + // ============================================ + // Private Helpers + // ============================================ + + /** + * Apply delta inventory changes to an item list. + * Fires onDidChangeContent for any content_changed or removed items. + */ + private _applyInventoryChanges( + entry: ObjectEntry, + object_id: string, + prim_id: string, + inventory: ObjectInventoryItem[], + changes: InventoryChanges + ): void { + if (changes.added) { + inventory.push(...changes.added); + } + if (changes.removed) { + const removedSet = new Set(changes.removed); + for (let i = inventory.length - 1; i >= 0; i--) { + if (removedSet.has(inventory[i].item_id)) { + inventory.splice(i, 1); + } + } + for (const item_id of changes.removed) { + entry.contentCache.delete(item_id); + this._onDidChangeContent.fire({ object_id, prim_id, item_id }); + } + } + if (changes.modified) { + for (const mod of changes.modified) { + const idx = inventory.findIndex((i) => i.item_id === mod.item_id); + if (idx !== -1) { + inventory[idx] = mod; + } + } + } + if (changes.content_changed) { + for (const item_id of changes.content_changed) { + entry.contentCache.delete(item_id); + this._onDidChangeContent.fire({ object_id, prim_id, item_id }); + } + } + if (changes.running_changed) { + for (const rc of changes.running_changed) { + const item = inventory.find((i) => i.item_id === rc.item_id); + if (item) { + item.running = rc.running; + } + } + } + } + + /** + * Evict all cached content belonging to a specific prim from the entry's cache. + * Used when inventory is fully replaced or a linked prim is removed. + * Fires onDidChangeContent for each evicted item. + */ + private _evictPrimCache(entry: ObjectEntry, object_id: string, prim_id: string): void { + const inventory = + prim_id === object_id + ? entry.object.inventory + : entry.object.linked_objects?.find((lo) => lo.link_id === prim_id)?.inventory ?? []; + + for (const item of inventory) { + if (entry.contentCache.delete(item.item_id)) { + this._onDidChangeContent.fire({ object_id, prim_id, item_id: item.item_id }); + } + } + } +} diff --git a/src/websockclient.ts b/src/websockclient.ts index c2974e8..a7a51c7 100644 --- a/src/websockclient.ts +++ b/src/websockclient.ts @@ -64,6 +64,7 @@ import * as vscode from "vscode"; import WebSocket from "ws"; +import { logDebug } from "./utils"; /** * JSON-RPC 2.0 message types @@ -448,13 +449,47 @@ export class JSONRPCClient extends WebsockClient implements JSONRPCInterface { super(context, url); } + private logIncomingMessage(message: JSONRPCMessage): void { + if (this.isJSONRPCRequest(message)) { + logDebug(`[JSON-RPC] <- request method=${message.method} id=${String(message.id)}`); + return; + } + + if (this.isJSONRPCNotification(message)) { + logDebug(`[JSON-RPC] <- notification method=${message.method}`); + return; + } + + if (this.isJSONRPCResponse(message)) { + const status = message.error ? "error" : "result"; + logDebug(`[JSON-RPC] <- response id=${String(message.id)} status=${status}`); + } + } + + private logOutgoingMessage(message: JSONRPCMessage): void { + if (this.isJSONRPCRequest(message)) { + logDebug(`[JSON-RPC] -> request method=${message.method} id=${String(message.id)}`); + return; + } + + if (this.isJSONRPCNotification(message)) { + logDebug(`[JSON-RPC] -> notification method=${message.method}`); + return; + } + + if (this.isJSONRPCResponse(message)) { + const status = message.error ? "error" : "result"; + logDebug(`[JSON-RPC] -> response id=${String(message.id)} status=${status}`); + } + } + /** * Handles incoming WebSocket messages with JSON-RPC support */ protected handleMessage(data: WebSocket.RawData): void { try { const message = JSON.parse(data.toString()) as JSONRPCMessage; - // console.log("Received JSON-RPC message:", message); + this.logIncomingMessage(message); if (this.isJSONRPCResponse(message)) { this.handleJSONRPCResponse(message); @@ -674,6 +709,7 @@ export class JSONRPCClient extends WebsockClient implements JSONRPCInterface { * Sends a JSON-RPC message */ private sendJSONRPCMessage(message: JSONRPCMessage): boolean { + this.logOutgoingMessage(message); return this.sendMessage(message); } From 497173ef709e9c245b5a8b355a643a6cb0289bba Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Wed, 1 Jul 2026 11:37:20 -0700 Subject: [PATCH 5/9] object publishing checkpoint. --- doc/Message_Interfaces.md | 24 ++++ src/extension.ts | 10 ++ src/synchservice.ts | 32 ++++++ src/viewereditwsclient.ts | 6 + src/vscode/objectcontentinterfaces.ts | 10 ++ src/vscode/objectcontentprovider.ts | 160 ++++++++++++++++++++++++-- src/vscode/objectcontentservice.ts | 20 +++- 7 files changed, 248 insertions(+), 14 deletions(-) diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 022378a..605e6a9 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -170,6 +170,8 @@ WebSocket connects → session.handshake → session.ok | `runtime.error` | Viewer → Extension | Notification | `RuntimeError` | | `object.publish` | Viewer → Extension | Notification | `ObjectPublishMessage` | | `object.unpublish` | Viewer → Extension | Notification | `ObjectUnpublishMessage` | +| `object.unpublish` | Extension → Viewer | Call | `ObjectUnpublishParams` | +| `object.unpublish` (response) | Viewer → Extension | Response | `ObjectUnpublishResponse` | | `object.update` | Viewer → Extension | Notification | `ObjectUpdateMessage` | | `object.content.get` | Extension → Viewer | Call | `ObjectContentGetParams` | | `object.content.get` (response) | Viewer → Extension | Response | `ObjectContentGetResponse` | @@ -780,6 +782,28 @@ interface ObjectUnpublishMessage { - `object_id`: UUID of the root prim that is being unpublished - `reason` (optional): Human-readable explanation (e.g. `"object deleted"`, `"out of range"`) +**JSON-RPC Method:** `object.unpublish` (call from extension to viewer) + +The extension may also call `object.unpublish` to manually stop tracking an object. The viewer will stop publishing it and send a corresponding `object.unpublish` notification back to the caller. + +```typescript +interface ObjectUnpublishParams { + object_id: string; // UUID of the root prim to unpublish +} + +interface ObjectUnpublishResponse { + success: boolean; + object_id?: string; +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim to unpublish. +- `success`: `true` if the object was published and has been removed. + +**Note:** The viewer also sends an `object.unpublish` notification to the caller immediately after responding. Extensions should handle that notification idempotently. + --- ### ObjectUpdate diff --git a/src/extension.ts b/src/extension.ts index e5c92ce..0180cf2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -75,6 +75,16 @@ export function activate(context: vscode.ExtensionContext): void { if (slIdx !== -1) { vscode.workspace.updateWorkspaceFolders(slIdx, 1); } + } else if (type === "updated") { + if (slIdx !== -1) { + const entry = objectContentService.getObject(object_id); + if (entry) { + vscode.workspace.updateWorkspaceFolders(slIdx, 1, { + uri: rootUri(object_id), + name: entry.object.object_name, + }); + } + } } }) ); diff --git a/src/synchservice.ts b/src/synchservice.ts index e5270cd..4ac65f8 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -42,6 +42,7 @@ import { getLanguageConfig } from "./shared/lexer"; import { HostInterface } from "./interfaces/hostinterface"; import { SyncedFileDecorator } from "./vscode/SyncedFileDecorator"; import { ObjectContentService } from "./vscode/objectcontentservice"; +import { SL_SCHEME, SL_AUTHORITY } from "./vscode/objectcontentprovider"; type ParsedTempFile = { scriptName: string; scriptId: string; extension: string, language: ScriptLanguage }; @@ -372,6 +373,7 @@ export class SynchService implements vscode.Disposable { }, onObjectUpdate: (msg: ObjectUpdateMessage): any => { logDebug(`[object.update] object_id=${msg.object_id}`); + logDebug(`[object.update] object_name=${msg.object_name}`); ObjectContentService.getInstance().handleUpdate(msg); }, }; @@ -488,6 +490,7 @@ export class SynchService implements vscode.Disposable { this._onDidChangeConnectionState.fire(true); await this.handleLaunchParams(); + await this.requestWorkspaceObjects(); } private onDisconnect(params: SessionDisconnect): void { @@ -1007,6 +1010,35 @@ export class SynchService implements vscode.Disposable { // handleLaunchParams is called from onHandshakeOk } + private async requestWorkspaceObjects(): Promise { + if (!this.websocket?.isConnected()) { return; } + + const service = ObjectContentService.getInstance(); + const folders = vscode.workspace.workspaceFolders ?? []; + + for (const folder of folders) { + if (folder.uri.scheme !== SL_SCHEME || folder.uri.authority !== SL_AUTHORITY) { + continue; + } + + const object_id = folder.uri.path.slice(1); // strip leading "/" + if (!object_id || service.hasObject(object_id)) { + continue; // already published + } + + try { + const result = await this.websocket.requestObject({ object_id }); + if (result.object) { + service.handlePublish({ object: result.object }); + } else if (result.success === false) { + logDebug(`[requestWorkspaceObjects] viewer rejected ${object_id}: ${result.message ?? "unknown"}`); + } + } catch (err) { + logDebug(`[requestWorkspaceObjects] error requesting ${object_id}: ${err}`); + } + } + } + private async handleLaunchParams(): Promise { const objectId = this.pendingLaunchObjectId; const scriptId = this.pendingLaunchScriptId; diff --git a/src/viewereditwsclient.ts b/src/viewereditwsclient.ts index 9748d2c..ca00e61 100644 --- a/src/viewereditwsclient.ts +++ b/src/viewereditwsclient.ts @@ -21,6 +21,8 @@ import { ObjectItemDeleteResponse, ObjectScriptSetRunningParams, ObjectScriptSetRunningResponse, + ObjectUnpublishParams, + ObjectUnpublishResponse, ObjectRequestParams, ObjectRequestResponse, } from "./vscode/objectcontentinterfaces"; @@ -279,6 +281,10 @@ export class ViewerEditWSClient extends JSONRPCClient { return this.call("object.script.set_running", params); } + public unpublishObject(params: ObjectUnpublishParams): Promise { + return this.call("object.unpublish", params); + } + public requestObject(params: ObjectRequestParams): Promise { return this.call("object.request", params); } diff --git a/src/vscode/objectcontentinterfaces.ts b/src/vscode/objectcontentinterfaces.ts index d4ee775..e0307ee 100644 --- a/src/vscode/objectcontentinterfaces.ts +++ b/src/vscode/objectcontentinterfaces.ts @@ -98,6 +98,16 @@ export interface ObjectUnpublishMessage { reason?: string; } +/** object.unpublish — Extension → Viewer (call) */ +export interface ObjectUnpublishParams { + object_id: string; +} + +export interface ObjectUnpublishResponse { + success: boolean; + object_id?: string; +} + /** Delta changes for inventory items */ export interface InventoryChanges { added?: ObjectInventoryItem[]; diff --git a/src/vscode/objectcontentprovider.ts b/src/vscode/objectcontentprovider.ts index aaddf2a..222757f 100644 --- a/src/vscode/objectcontentprovider.ts +++ b/src/vscode/objectcontentprovider.ts @@ -9,6 +9,7 @@ import { ObjectContentService } from "./objectcontentservice"; import { ViewerEditWSClient } from "../viewereditwsclient"; import { InventoryItemType, + LinkedObject, ObjectContentSaveVM, ObjectInventoryItem, ScriptVM, @@ -93,6 +94,46 @@ function isUUID(s: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); } +/** + * Returns the display name to use for a linked prim's directory entry in readDirectory. + * Uses link_name when unique among siblings; disambiguates with link_number when names collide. + * Falls back to link_id when link_name is empty. + */ +function linkedPrimDirName(lo: LinkedObject, allLinked: LinkedObject[]): string { + if (!lo.link_name) return lo.link_id; + const hasDuplicate = allLinked.some( + (other) => other.link_id !== lo.link_id && other.link_name === lo.link_name + ); + return hasDuplicate ? `${lo.link_name} (${lo.link_number})` : lo.link_name; +} + +/** + * Resolves a linked prim directory segment (as returned by readDirectory) back to its link_id. + * Accepts: plain UUID, "Name (link_number)" disambiguated format, or plain name. + */ +function resolveLinkedPrimId( + segment: string, + linkedObjects: LinkedObject[] +): string | undefined { + // UUID — used by internal API calls and content-change notifications + const byId = linkedObjects.find((lo) => lo.link_id === segment); + if (byId) return byId.link_id; + + // "Name (N)" — disambiguated display name + const m = segment.match(/^(.*) \((\d+)\)$/); + if (m) { + const baseName = m[1]; + const linkNum = parseInt(m[2], 10); + const found = linkedObjects.find( + (lo) => lo.link_name === baseName && lo.link_number === linkNum + ); + if (found) return found.link_id; + } + + // Plain name — for unique names + return linkedObjects.find((lo) => lo.link_name === segment)?.link_id; +} + /** * Find an item in an inventory list by its display name. * Returns the item_id if found, undefined otherwise. @@ -144,11 +185,11 @@ function parseUri( if (parts.length === 2) { // Is seg1 a linked prim (directory) or an item (file)? - const isLinkedPrim = - entry?.object.linked_objects?.some((lo) => lo.link_id === seg1) ?? false; + const linkedObjects = entry?.object.linked_objects ?? []; + const resolvedLinkId = resolveLinkedPrimId(seg1, linkedObjects); - if (isLinkedPrim) { - return { root_id, link_id: seg1, isDirectory: true }; + if (resolvedLinkId !== undefined) { + return { root_id, link_id: resolvedLinkId, isDirectory: true }; } // seg1 is either a UUID or a display name @@ -168,7 +209,8 @@ function parseUri( } if (parts.length === 3) { - const link_id = parts[1]; + const linkedObjects = entry?.object.linked_objects ?? []; + const link_id = resolveLinkedPrimId(parts[1], linkedObjects) ?? parts[1]; const seg2 = parts[2]; // seg2 is either a UUID or a display name @@ -281,6 +323,9 @@ export class ObjectContentProvider implements vscode.FileSystemProvider, vscode. private _onDidChangeFile = new vscode.EventEmitter(); readonly onDidChangeFile = this._onDidChangeFile.event; + /** Maps object_id → (link_id → last-known dir segment name) for change notification lookup */ + private _linkedDirNames = new Map>(); + private disposables: vscode.Disposable[] = []; constructor( @@ -290,7 +335,22 @@ export class ObjectContentProvider implements vscode.FileSystemProvider, vscode. // Forward content invalidations to VS Code as Changed events this.disposables.push( service.onDidChangeContent(({ object_id, prim_id, item_id }) => { - const uri = itemUri(object_id, prim_id, item_id); + // Build the URI using the same display-name segment that readDirectory exposes, + // so VS Code can match the notification to the correct open editor. + let uri: vscode.Uri; + if (prim_id === object_id) { + uri = itemUri(object_id, prim_id, item_id); + } else { + const entry2 = service.getObject(object_id); + const allLinked = entry2?.object.linked_objects ?? []; + const lo = allLinked.find((l) => l.link_id === prim_id); + const linkSegment = lo ? linkedPrimDirName(lo, allLinked) : prim_id; + uri = vscode.Uri.from({ + scheme: SL_SCHEME, + authority: SL_AUTHORITY, + path: `/${object_id}/${linkSegment}/${item_id}`, + }); + } // Defer to ensure VS Code processes even when not focused setTimeout(() => { this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); @@ -298,16 +358,83 @@ export class ObjectContentProvider implements vscode.FileSystemProvider, vscode. }), // Forward tree changes (added/removed objects) as directory changes - service.onDidChangeObjects(({ type, object_id }) => { + service.onDidChangeObjects((event) => { + const { type, object_id } = event; const uri = rootUri(object_id); // Defer to ensure VS Code processes even when not focused setTimeout(() => { if (type === "added") { this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Created, uri }]); } else if (type === "removed") { + this._linkedDirNames.delete(object_id); this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Deleted, uri }]); } else { - this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + const { added_link_ids, removed_link_ids } = event; + const fileEvents: vscode.FileChangeEvent[] = []; + + if (added_link_ids?.length || removed_link_ids?.length) { + // Structural change: fire Deleted for removed dirs (using cached + // names) and Created for added dirs. + const nameMap = this._linkedDirNames.get(object_id) ?? new Map(); + if (!this._linkedDirNames.has(object_id)) { + this._linkedDirNames.set(object_id, nameMap); + } + + for (const link_id of removed_link_ids ?? []) { + const segment = nameMap.get(link_id); + if (segment) { + fileEvents.push({ + type: vscode.FileChangeType.Deleted, + uri: vscode.Uri.from({ + scheme: SL_SCHEME, + authority: SL_AUTHORITY, + path: `/${object_id}/${segment}`, + }), + }); + nameMap.delete(link_id); + } + } + + const entry = service.getObject(object_id); + const allLinked = entry?.object.linked_objects ?? []; + for (const link_id of added_link_ids ?? []) { + const lo = allLinked.find((l) => l.link_id === link_id); + if (lo) { + const segment = linkedPrimDirName(lo, allLinked); + nameMap.set(link_id, segment); + fileEvents.push({ + type: vscode.FileChangeType.Created, + uri: vscode.Uri.from({ + scheme: SL_SCHEME, + authority: SL_AUTHORITY, + path: `/${object_id}/${segment}`, + }), + }); + } + } + + // Changed on root so VS Code re-calls readDirectory + fileEvents.push({ type: vscode.FileChangeType.Changed, uri }); + } else { + // Non-structural update — refresh root and all current child dirs + fileEvents.push({ type: vscode.FileChangeType.Changed, uri }); + const entry = service.getObject(object_id); + if (entry?.object.linked_objects?.length) { + const allLinked = entry.object.linked_objects; + for (const lo of allLinked) { + fileEvents.push({ + type: vscode.FileChangeType.Changed, + uri: vscode.Uri.from({ + scheme: SL_SCHEME, + authority: SL_AUTHORITY, + path: `/${object_id}/${linkedPrimDirName(lo, allLinked)}`, + }), + }); + } + } + } + + this._onDidChangeFile.fire(fileEvents); } }, 0); }), @@ -389,10 +516,19 @@ export class ObjectContentProvider implements vscode.FileSystemProvider, vscode. for (const item of entry.object.inventory) { results.push([displayName(item), vscode.FileType.File]); } - for (const lo of entry.object.linked_objects ?? []) { - // Use link_id (UUID) as the directory name so URIs remain stable. - // link_name is used as display label via workspace folder naming. - results.push([lo.link_id, vscode.FileType.Directory]); + const allLinked = entry.object.linked_objects ?? []; + // Cache dir names so the change-notification handler can fire Deleted + // events with the correct URI even after the entry has been updated. + let nameMap = this._linkedDirNames.get(root_id); + if (!nameMap) { + nameMap = new Map(); + this._linkedDirNames.set(root_id, nameMap); + } + for (const lo of allLinked) { + // Use a human-readable display name; disambiguate duplicates with link_number. + const segment = linkedPrimDirName(lo, allLinked); + nameMap.set(lo.link_id, segment); + results.push([segment, vscode.FileType.Directory]); } } diff --git a/src/vscode/objectcontentservice.ts b/src/vscode/objectcontentservice.ts index c0aa513..23410af 100644 --- a/src/vscode/objectcontentservice.ts +++ b/src/vscode/objectcontentservice.ts @@ -23,6 +23,10 @@ import { export interface ObjectTreeChangeEvent { type: "added" | "removed" | "updated"; object_id: string; + /** link_ids of prims newly added to the linkset (only set when type === "updated") */ + added_link_ids?: string[]; + /** link_ids of prims removed from the linkset (only set when type === "updated") */ + removed_link_ids?: string[]; } /** Fired when cached content for a specific item is invalidated or needs refresh */ @@ -100,6 +104,9 @@ export class ObjectContentService implements vscode.Disposable { entry.object.object_name = msg.object_name; } + let added_link_ids: string[] | undefined; + let removed_link_ids: string[] | undefined; + if (msg.changes) { // Delta update if (msg.changes.inventory) { @@ -158,15 +165,24 @@ export class ObjectContentService implements vscode.Disposable { this._evictPrimCache(entry, msg.object_id, msg.object_id); } if (msg.linked_objects !== undefined) { + const oldLinks = entry.object.linked_objects ?? []; + const newIdSet = new Set(msg.linked_objects.map((lo) => lo.link_id)); + const oldIdSet = new Set(oldLinks.map((lo) => lo.link_id)); + removed_link_ids = oldLinks + .filter((lo) => !newIdSet.has(lo.link_id)) + .map((lo) => lo.link_id); + added_link_ids = msg.linked_objects + .filter((lo) => !oldIdSet.has(lo.link_id)) + .map((lo) => lo.link_id); // Evict cache for all replaced linked prims - for (const lo of entry.object.linked_objects ?? []) { + for (const lo of oldLinks) { this._evictPrimCache(entry, msg.object_id, lo.link_id); } entry.object.linked_objects = msg.linked_objects; } } - this._onDidChangeObjects.fire({ type: "updated", object_id: msg.object_id }); + this._onDidChangeObjects.fire({ type: "updated", object_id: msg.object_id, added_link_ids, removed_link_ids }); } // ============================================ From 6f03dca63c355e2709cf7127a10a692ef2477d31 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Wed, 1 Jul 2026 16:53:22 -0700 Subject: [PATCH 6/9] Large refactor, converting `normalizedPath` into `vscode.uri` where that can be used and `StringURI` when vscode is not available. --- .github/copilot-instructions.md | 28 +- src/configservice.ts | 14 +- src/interfaces/configinterface.ts | 11 +- src/interfaces/hostinterface.ts | 264 +++++++++++++++--- src/pluginsupport.ts | 56 ++-- src/scriptsync.ts | 29 +- src/server/nodehost.ts | 92 +++--- src/shared/conditionalprocessor.ts | 14 +- src/shared/diagnostics.ts | 8 +- src/shared/includeprocessor.ts | 30 +- src/shared/languagerepository.ts | 10 +- src/shared/languageservice.ts | 12 +- src/shared/lexer.ts | 8 +- src/shared/lexingpreprocessor.ts | 8 +- src/shared/linemapper.ts | 24 +- src/shared/macroprocessor.ts | 10 +- src/shared/parser.ts | 51 ++-- src/shared/sharedutils.ts | 14 +- src/synchservice.ts | 22 +- .../suite/conditional-diagnostics.test.ts | 4 +- src/test/suite/diagnostic-integration.test.ts | 115 ++------ src/test/suite/helpers/expectMapping.ts | 6 +- src/test/suite/helpers/mockHost.ts | 116 ++++++++ src/test/suite/include-diagnostics.test.ts | 134 ++++----- .../suite/include-disk-integration.test.ts | 165 +++++++---- src/test/suite/lexer-diagnostics.test.ts | 4 +- src/test/suite/lexingpreprocessor.test.ts | 260 ++++++++--------- src/test/suite/line-mapping.test.ts | 4 +- src/test/suite/macro-diagnostics.test.ts | 4 +- src/test/suite/nodehost.test.ts | 28 +- src/test/suite/parse-line-mappings.test.ts | 85 +----- src/test/suite/parser-diagnostics.test.ts | 8 +- .../parser-directive-diagnostics.test.ts | 6 +- src/test/suite/parser.test.ts | 141 ++-------- src/test/suite/require-table.test.ts | 170 ++++------- src/utils.ts | 226 +++++++-------- 36 files changed, 1103 insertions(+), 1078 deletions(-) create mode 100644 src/test/suite/helpers/mockHost.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8e36ef9..78650fb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -223,7 +223,7 @@ A standalone Node runtime implementation of `HostInterface` now exists at `src/s Capabilities: * File I/O (read/write text, JSON, YAML, TOML) using native fs + `js-yaml` + `@iarna/toml`. * Include resolution logic ported from the VS Code host: supports relative paths, explicit `.` / `./subdir`, workspace-root relative paths, and wildcard directory patterns (e.g. `**/include/`). Uses `glob` for wildcard matching. -* Path normalization with `NormalizedPath` branding preserved. +* Path identification uses `StringUri` branding throughout. * Workspace roots provided at construction (`new NodeHost({ roots, config })`). * Minimal logger injection (optional) with no-op defaults. * Config access fully delegated to injected `FullConfigInterface` implementation—no direct env or global lookups inside NodeHost. @@ -243,7 +243,7 @@ Guidelines: 1. Do not introduce VS Code imports into `src/server/`. 2. Keep feature parity between extension host and NodeHost include resolution. 3. Add new serialization helpers via optional methods (feature-detect in callers) rather than expanding core method contracts. -4. Always return `NormalizedPath` for resolved files. +4. Always return `StringUri` for resolved files. Future Extensions: * Optional file watching (likely via `fs.watch` or chokidar) for cache invalidation. @@ -266,24 +266,24 @@ Most services use optional chaining and the `maybe()` utility for safe property Always use workspace-relative paths for security. Include paths are configurable via `includePaths` setting with patterns like `["./include/", "include/", "*/include/", "."]`. -#### NormalizedPath Abstraction (2025-09 Update) +#### StringUri Abstraction (2025-09 Update, replaced NormalizedPath 2026-07) -All internal path handling in the preprocessor layer now uses `NormalizedPath`, a branded string type produced by `normalizePath()` (see `llsharedutils`). This replaces previous reliance on `vscode.Uri` within core logic and tests. +All internal path handling in the preprocessor layer uses `StringUri`, a branded `string` type produced by `filePathToStringUri()` (see `hostinterface.ts`). This replaced the earlier `NormalizedPath`/`normalizePath()` approach and the reliance on `vscode.Uri` within core logic and tests. Key guidelines: -- Do not store or compare raw/relative paths directly; always normalize first. -- Equality checks are simple strict equality (`===`) because normalization canonicalizes separators and casing rules (platform appropriate). -- Tests must no longer access `.fsPath` or other `Uri` properties—compare the `NormalizedPath` values directly. -- When constructing mappings (`LineMapping`), assign `sourceFile: NormalizedPath`. +- Do not store or compare raw/relative paths directly; always convert to `StringUri` first. +- Equality checks use `uriEquals()` (case-insensitive on Windows for `file://` URIs); use `uriKey()` for Map/Set keys. +- Tests compare `StringUri` values directly, not `.fsPath` or other `Uri` properties. +- When constructing mappings (`LineMapping`), assign `sourceFile: StringUri`. #### HostInterface for Includes (formerly FileInterface) `IncludeProcessor` now depends on an injected `HostInterface` (renamed from earlier `FileInterface` for broader future responsibilities) instead of directly using VS Code APIs. Implementations must provide: ``` -readFile(path: NormalizedPath): Promise -exists(path: NormalizedPath): Promise -resolveFile(filename: string, from: NormalizedPath, extensions?: string[], includePaths?: string[]): Promise +readFile(path: StringUri): Promise +exists(path: StringUri): Promise +resolveFile(filename: string, from: StringUri, extensions?: string[], includePaths?: string[]): Promise ``` Test shims may implement minimal logic (e.g., in-memory maps). For realistic include resolution tests, provide a hybrid in-memory + disk implementation and pass it to `new IncludeProcessor(fsImpl)`. @@ -320,7 +320,7 @@ Deprecated/Removed (late Sept 2025): free helpers `getConfig` / `setConfig`. `processInclude` signature: ``` -processInclude(filename: string, sourceFile: NormalizedPath, isRequire: boolean, state: PreprocessorState) +processInclude(filename: string, sourceFile: StringUri, isRequire: boolean, state: PreprocessorState) ``` Static helper methods like `pathToGlobPattern` and `getIncludeDirectories` have been removed; tests referring to them should be deleted or rewritten. @@ -354,7 +354,7 @@ expectMapping(mapping, processedLine, originalLine, filePathNormalized); expectMappings(arrayOfMappings, [ [processed, original, file], ... ]); ``` -Adopt these helpers when adding or modifying mapping tests. They perform strict equality on `NormalizedPath` and provide clearer failure messaging. +Adopt these helpers when adding or modifying mapping tests. They perform strict equality on `StringUri` and provide clearer failure messaging. ## Maintaining These Instructions @@ -373,7 +373,7 @@ Examples of updates to include: - Removed legacy global configuration helpers (`getConfig`, `setConfig`); explicit dependency injection via `host.config` only. - HostInterface trimmed: configuration & path access consolidated under `FullConfigInterface` implementation (`LLConfigService`). - Updated services and sync logic to use `LLConfigService.getInstance()` only at composition boundaries; core logic depends on abstracted `HostInterface` + `FullConfigInterface`. -- Ensured path branding (`NormalizedPath`) throughout preprocessing and language data flows. +- Ensured URI branding (`StringUri`) throughout preprocessing and language data flows. - Guidance: New settings belong in `ConfigKey` + `LLConfigService`; avoid reintroducing host-level config APIs. ### 2025-10 Nested Require Processing (Oct 10) diff --git a/src/configservice.ts b/src/configservice.ts index 842f213..fcb374a 100644 --- a/src/configservice.ts +++ b/src/configservice.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode"; import { hasWorkspace } from "./utils"; import { ConfigKey, ConfigScope, FullConfigInterface } from "./interfaces/configinterface"; -import { normalizePath, NormalizedPath } from "./interfaces/hostinterface"; +import { filePathToStringUri, StringUri } from "./interfaces/hostinterface"; /** Number of seconds to display status bar messages */ export const STATUS_BAR_TIMEOUT_SECONDS = 3; @@ -75,16 +75,16 @@ export class ConfigService implements vscode.Disposable, FullConfigInterface { } // ConfigInterface path methods ------------------------------------------------- - public async getExtensionInstallPath(): Promise { - return normalizePath(ConfigService.getExtensionPath().fsPath); + public async getExtensionInstallPath(): Promise { + return filePathToStringUri(ConfigService.getExtensionPath().fsPath); } - public async getGlobalConfigPath(): Promise { - return normalizePath((await ConfigService.getGlobalConfigPath()).fsPath); + public async getGlobalConfigPath(): Promise { + return filePathToStringUri((await ConfigService.getGlobalConfigPath()).fsPath); } - public async getWorkspaceConfigPath(): Promise { - return normalizePath((await ConfigService.getConfigPath()).fsPath); + public async getWorkspaceConfigPath(): Promise { + return filePathToStringUri((await ConfigService.getConfigPath()).fsPath); } // Session value helpers ------------------------------------------------------- diff --git a/src/interfaces/configinterface.ts b/src/interfaces/configinterface.ts index 023bc45..afea4b6 100644 --- a/src/interfaces/configinterface.ts +++ b/src/interfaces/configinterface.ts @@ -3,11 +3,10 @@ * Abstraction layer for configuration access so core logic remains framework-agnostic. * * This mirrors responsibilities currently handled inside LLConfigService but avoids - * any direct dependency on VS Code types. All paths MUST be normalized before - * returning using the NormalizedPath branding from hostinterface. + * any direct dependency on VS Code types. All paths are returned as StringUri. */ -import { NormalizedPath } from './hostinterface'; +import { StringUri } from './hostinterface'; /** Keys used by configuration (mirrors LLConfigNames). */ export enum ConfigKey { @@ -60,10 +59,10 @@ export interface ConfigInterface { setConfig(key: ConfigKey, value: T, scope?: ConfigScope): Promise; /** Path helpers analogous to LLConfigService static methods. */ - getExtensionInstallPath(): Promise; - getGlobalConfigPath(): Promise; + getExtensionInstallPath(): Promise; + getGlobalConfigPath(): Promise; /** Workspace-level config path (may fallback to global if local not enabled). */ - getWorkspaceConfigPath(): Promise; + getWorkspaceConfigPath(): Promise; /** Arbitrary session-scoped values (non-persisted) similar to SessionConfigs. */ getSessionValue(key: ConfigKey): T | undefined; diff --git a/src/interfaces/hostinterface.ts b/src/interfaces/hostinterface.ts index 2245c86..f2b3691 100644 --- a/src/interfaces/hostinterface.ts +++ b/src/interfaces/hostinterface.ts @@ -2,23 +2,30 @@ * @file hostinterface.ts * Copyright (C) 2025, Linden Research, Inc. */ -import * as path from "path"; import * as fs from "fs"; import { FullConfigInterface } from "./configinterface"; //============================================================================= -declare const __NormalizedPathBrand: unique symbol; -export type NormalizedPath = string & { readonly [__NormalizedPathBrand]: true }; - -export function normalizePath(filePath: string): NormalizedPath { - return path.normalize(filePath) as NormalizedPath; -} - -export function normalizeJoinPath(...paths: string[]): NormalizedPath { - return path.normalize(path.join(...paths)) as NormalizedPath; -} +// StringUri - URI-based file identification +//============================================================================= +declare const __StringUriBrand: unique symbol; +/** + * A branded string type representing a URI. + * Used for file identification throughout the preprocessor. + * + * Supported schemes: + * - file:// - Standard filesystem paths + * - workspace:///{folderName}/{path} - Workspace-relative paths + * + * Use helper functions to create and manipulate: + * - filePathToStringUri() - Convert filesystem path to file:// URI + * - stringUriToFilePath() - Extract path from file:// URI (null for other schemes) + * - resolveUri() - Resolve relative path against base URI + * - uriEquals() - Compare URIs with proper case handling + */ +export type StringUri = string & { readonly [__StringUriBrand]: true }; -export async function fileExists(filePath: NormalizedPath): Promise { +export async function fileExists(filePath: string): Promise { try { await fs.promises.stat(filePath); return true; @@ -27,17 +34,194 @@ export async function fileExists(filePath: NormalizedPath): Promise { } } -export function splitFilename(filename: NormalizedPath): { basepath: string; filename: string } { - const dirname = path.dirname(filename); - const basename = path.basename(filename); +//============================================================================= +// StringUri helper functions +//============================================================================= + +/** + * Convert a filesystem path to a file:// URI string. + * Handles Windows drive letters and path separators. + */ +export function filePathToStringUri(filePath: string): StringUri { + // Normalize path separators to forward slashes + let normalized = filePath.replace(/\\/g, "/"); + + // Handle Windows drive letters: C:/foo → file:///C:/foo + if (/^[a-zA-Z]:/.test(normalized)) { + return `file:///${normalized}` as StringUri; + } + + // Unix absolute paths: /foo → file:///foo + if (normalized.startsWith("/")) { + return `file://${normalized}` as StringUri; + } + + throw new Error(`Cannot convert relative path to URI: ${filePath}. Use resolveUri(baseUri, relativePath) for relative paths.`); +} + +/** + * Extract filesystem path from a file:// URI. + * Returns null for non-file:// URIs (workspace://, sl://, etc.) + */ +export function stringUriToFilePath(uri: StringUri): string | null { + if (!uri.startsWith("file://")) { + return null; + } + + // Remove file:// prefix + let filePath = uri.slice(7); + + // Decode URI encoding + filePath = decodeURIComponent(filePath); + + // Windows: file:///C:/foo → C:/foo (remove leading slash before drive) + if (/^\/[a-zA-Z]:/.test(filePath)) { + filePath = filePath.slice(1); + } + + return filePath; +} + +/** + * Resolve a relative path against a base URI. + * Works for file:// and workspace:// URIs. + */ +export function resolveUri(base: StringUri, relativePath: string): StringUri { + // Find the last slash to get directory + const lastSlash = base.lastIndexOf("/"); + if (lastSlash === -1) { + throw new Error(`Invalid base URI: ${base}`); + } + + // Get base directory (everything up to last slash) + const baseDir = base.slice(0, lastSlash); - // If dirname is "." it means there was no path component - const pathPart = dirname === "." ? "" : dirname; + // Normalize relative path separators + const normalizedRelative = relativePath.replace(/\\/g, "/"); - return { - basepath: pathPart, - filename: basename - }; + // Simple resolution: append relative to base directory + // Handle ../ and ./ segments + const parts = `${baseDir}/${normalizedRelative}`.split("/"); + const resolved: string[] = []; + + for (const part of parts) { + if (part === "..") { + resolved.pop(); + } else if (part !== "." && part !== "") { + resolved.push(part); + } + } + + // Reconstruct with proper scheme prefix + const result = resolved.join("/"); + + // Ensure file:// URIs have triple slash for absolute paths + if (result.startsWith("file:/") && !result.startsWith("file:///")) { + return result.replace("file:/", "file:///") as StringUri; + } + + return result as StringUri; +} + +/** + * Get the filename (last path component) from a URI. + */ +export function uriFileName(uri: StringUri): string { + const lastSlash = uri.lastIndexOf("/"); + if (lastSlash === -1) { + return uri; + } + return decodeURIComponent(uri.slice(lastSlash + 1)); +} + +/** + * Get the directory URI (parent) from a URI. + */ +export function uriDirname(uri: StringUri): StringUri { + const lastSlash = uri.lastIndexOf("/"); + if (lastSlash === -1) { + return uri; + } + return uri.slice(0, lastSlash) as StringUri; +} + +/** + * Compare two URIs for equality. + * Handles case-insensitivity on Windows for file:// URIs. + */ +export function uriEquals(a: StringUri, b: StringUri): boolean { + if (a === b) return true; + + // Case-insensitive comparison for file:// URIs on Windows + if ( + process.platform === "win32" && + a.startsWith("file://") && + b.startsWith("file://") + ) { + return a.toLowerCase() === b.toLowerCase(); + } + + return false; +} + +/** + * Normalize a URI for use as a Set/Map key. + * Lowercases file:// URIs on Windows. + */ +export function uriKey(uri: StringUri): string { + if (process.platform === "win32" && uri.startsWith("file://")) { + return uri.toLowerCase(); + } + return uri; +} + +/** + * A Set implementation that handles URI case-sensitivity correctly. + * On Windows, file:// URIs are compared case-insensitively. + */ +export class UriSet implements Iterable { + private map = new Map(); + + constructor(values?: Iterable) { + if (values) { + for (const uri of values) { + this.add(uri); + } + } + } + + add(uri: StringUri): this { + this.map.set(uriKey(uri), uri); + return this; + } + + has(uri: StringUri): boolean { + return this.map.has(uriKey(uri)); + } + + delete(uri: StringUri): boolean { + return this.map.delete(uriKey(uri)); + } + + clear(): void { + this.map.clear(); + } + + get size(): number { + return this.map.size; + } + + *[Symbol.iterator](): Iterator { + yield* this.map.values(); + } + + values(): IterableIterator { + return this.map.values(); + } + + forEach(callback: (uri: StringUri) => void): void { + this.map.forEach(callback); + } } //============================================================================= @@ -45,32 +229,30 @@ export interface HostInterface { /** Central configuration provider (framework-agnostic). */ config: FullConfigInterface; - existsInSameWorkspace(knownPath:string, desiredPath:string): Promise; + existsInSameWorkspace(knownUri: string, desiredUri: string): Promise; - exists(p: NormalizedPath, unsafe?: boolean): Promise; + exists(uri: StringUri, unsafe?: boolean): Promise; resolveFile( filename: string, // raw filename from directive - from: NormalizedPath, // path of current source file + from: StringUri, // URI of current source file extensions: string[], // possible extensions to try - includePaths?: string[], // additional include paths from options + includePaths?: string[], // additional include paths from options unsafe?: boolean, - ): Promise; - - readFile(p: NormalizedPath, unsafe?: boolean): Promise; - writeFile(p: NormalizedPath, content: string | Uint8Array): Promise; - readJSON(p: NormalizedPath, unsafe?: boolean): Promise; - readYAML(p: NormalizedPath, unsafe?: boolean): Promise; // optional - readTOML(p: NormalizedPath, unsafe?: boolean): Promise; // optional - writeJSON(p: NormalizedPath, data: any, pretty?: boolean): Promise; - writeYAML(p: NormalizedPath, data: any): Promise; // optional (not all hosts need YAML) - writeTOML(p: NormalizedPath, data: Record): Promise; // optional - - listWorkspaceFolders?(): Promise; // optional for non-workspace hosts - // Extension / capability discovery --------------------------------------- - isExtensionAvailable?(id: string): boolean; + ): Promise; + + readFile(uri: StringUri, unsafe?: boolean): Promise; + writeFile(uri: StringUri, content: string | Uint8Array): Promise; + readJSON(uri: StringUri, unsafe?: boolean): Promise; + readYAML(uri: StringUri, unsafe?: boolean): Promise; + readTOML(uri: StringUri, unsafe?: boolean): Promise; + writeJSON(uri: StringUri, data: any, pretty?: boolean): Promise; + writeYAML(uri: StringUri, data: any): Promise; + writeTOML(uri: StringUri, data: Record): Promise; - fileNameToUri(fileName: NormalizedPath): string; - uriToFileName(uri: string): NormalizedPath; + listWorkspaceFolders?(): Promise; + + // Extension / capability discovery + isExtensionAvailable?(id: string): boolean; // Path queries are now derived from config implementation, not host. } diff --git a/src/pluginsupport.ts b/src/pluginsupport.ts index 35a0e98..fa6dec1 100644 --- a/src/pluginsupport.ts +++ b/src/pluginsupport.ts @@ -3,8 +3,8 @@ * Copyright (C) 2025, Linden Research, Inc. */ import * as vscode from "vscode"; -import { HostInterface } from "./interfaces/hostinterface"; -import { NormalizedPath, normalizeJoinPath, normalizePath } from "./interfaces/hostinterface"; // migrated path abstractions +import * as path from "path"; +import { HostInterface, StringUri, filePathToStringUri, resolveUri } from "./interfaces/hostinterface"; import { LuaTypeDefinitions } from "./shared/luadefsinterface"; import { LuauDefsGenerator } from "./shared/luadefsgenerator"; import { DocsJsonGenerator } from "./shared/docsjsongenerator"; @@ -43,7 +43,7 @@ export class SelenePlugin extends BasePlugin { } const basename = `slua_${version}`; - let configPath: NormalizedPath; + let configPath: StringUri; configPath = await this.host.config.getWorkspaceConfigPath(); // Use the new generator @@ -67,18 +67,18 @@ export class SelenePlugin extends BasePlugin { // Language syntax export for Selene support // ======================================= private static async saveSLuaSeleneConfig( - configPath: NormalizedPath, + configPath: StringUri, filename: string, yamlContent: string, host: HostInterface, ): Promise { - const fullpath = normalizeJoinPath(configPath, filename); + const fullpath = resolveUri(configPath, filename); if (host.writeFile) { await host.writeFile(fullpath, yamlContent); return true; } // Fallback to VS Code API - await vscode.workspace.fs.writeFile(vscode.Uri.file(fullpath), Buffer.from(yamlContent, "utf8")); + await vscode.workspace.fs.writeFile(vscode.Uri.parse(fullpath as string), Buffer.from(yamlContent, "utf8")); return true; } @@ -120,16 +120,16 @@ export class SelenePlugin extends BasePlugin { } private static async updateSeleneConfig( - configPath: NormalizedPath, + configPath: StringUri, basename: string, host: HostInterface, ): Promise { - let folders: NormalizedPath[] = []; + let folders: StringUri[] = []; if (host.listWorkspaceFolders) { folders = await host.listWorkspaceFolders(); } else { const ws = vscode.workspace.workspaceFolders; - if (ws) folders = ws.map(f => normalizePath(f.uri.fsPath)); + if (ws) folders = ws.map(f => filePathToStringUri(f.uri.fsPath)); } if (folders.length === 0) { console.warn("No workspace folder found - cannot update selene.toml"); @@ -137,11 +137,11 @@ export class SelenePlugin extends BasePlugin { } let saved = false; for (const root of folders) { - const tomlPath = normalizeJoinPath(root, "selene.toml"); + const tomlPath = resolveUri(root, "selene.toml"); let seleneToml: any = {}; seleneToml = (await host?.readTOML(tomlPath)) || {}; - const fullConfig = normalizeJoinPath(configPath, `${basename}`); - const relativeConfig = vscode.workspace.asRelativePath(fullConfig); + const fullConfig = resolveUri(configPath, `${basename}`); + const relativeConfig = vscode.workspace.asRelativePath(vscode.Uri.parse(fullConfig as string)); seleneToml.std = "luau+" + relativeConfig; saved = await host.writeTOML(tomlPath, seleneToml); } @@ -177,7 +177,7 @@ export class LuaLSPPlugin extends BasePlugin { let configs = this.buildLuauLSPConfig(defs); // Determine config path via host first - let configPath: NormalizedPath; + let configPath: StringUri; configPath = await this.host.config.getWorkspaceConfigPath(); const defsFiles:{[k:string]:string} = {}; @@ -237,23 +237,23 @@ export class LuaLSPPlugin extends BasePlugin { } private async saveLuauLSPDefs( - configPath: NormalizedPath, + configPath: StringUri, version: any, defs: string, - ): Promise { + ): Promise { const basename = `slua_${version}.d.luau`; - const fullPath = normalizeJoinPath(configPath, basename); + const fullPath = resolveUri(configPath, basename); if (this.host.writeFile) { await this.host.writeFile(fullPath, defs); } else { - await vscode.workspace.fs.writeFile(vscode.Uri.file(fullPath), Buffer.from(defs, "utf8")); + await vscode.workspace.fs.writeFile(vscode.Uri.parse(fullPath as string), Buffer.from(defs, "utf8")); } - return fullPath; + return vscode.Uri.parse(fullPath as string).fsPath; } private async saveLuauLSPConstantDefs( - configPath: NormalizedPath - ) : Promise { + configPath: StringUri + ) : Promise { const basename = `slua_constants.d.luau`; const constants = [ ["__LINE__", "number"], @@ -271,28 +271,28 @@ export class LuaLSPPlugin extends BasePlugin { acc.push(`declare ${cur[0]} : ${cur[1]}`); return acc; },[]); - const fullPath = normalizeJoinPath(configPath, basename); + const fullPath = resolveUri(configPath, basename); if (this.host.writeFile) { await this.host.writeFile(fullPath, slua_constants.join("\n")); } else { - await vscode.workspace.fs.writeFile(vscode.Uri.file(fullPath), Buffer.from(slua_constants.join("\n"), "utf8")); + await vscode.workspace.fs.writeFile(vscode.Uri.parse(fullPath as string), Buffer.from(slua_constants.join("\n"), "utf8")); } - return fullPath; + return vscode.Uri.parse(fullPath as string).fsPath; } private async saveLuauLSPDocs( - configPath: NormalizedPath, + configPath: StringUri, version: any, docs: string, - ): Promise { + ): Promise { const basename = `slua_${version}.docs.json`; - const fullPath = normalizeJoinPath(configPath, basename); + const fullPath = resolveUri(configPath, basename); if (this.host.writeFile) { await this.host.writeFile(fullPath, docs); } else { - await vscode.workspace.fs.writeFile(vscode.Uri.file(fullPath), Buffer.from(docs, "utf8")); + await vscode.workspace.fs.writeFile(vscode.Uri.parse(fullPath as string), Buffer.from(docs, "utf8")); } - return fullPath; + return vscode.Uri.parse(fullPath as string).fsPath; } public async configureFromViewerCache( diff --git a/src/scriptsync.ts b/src/scriptsync.ts index ac42274..31c45d6 100644 --- a/src/scriptsync.ts +++ b/src/scriptsync.ts @@ -25,7 +25,8 @@ import { } from "./utils"; import { ScriptLanguage } from "./shared/languageservice"; import { CompilationResult, RuntimeDebug, RuntimeError } from "./viewereditwsclient"; -import { normalizePath } from "./interfaces/hostinterface"; +import { StringUri, uriEquals } from "./interfaces/hostinterface"; +import { vscodeUriToStringUri } from "./utils"; import { SynchService } from "./synchservice"; import { IncludeInfo } from "./shared/parser"; import { sha256 } from "js-sha256"; @@ -189,7 +190,11 @@ export class ScriptSync implements vscode.Disposable { } public getMasterFilePath(): string { - return path.normalize(this.masterDocument.fileName); + return this.masterDocument.uri.fsPath; + } + + public getMasterUri(): vscode.Uri { + return this.masterDocument.uri; } public getLanguage(): string { @@ -204,21 +209,21 @@ export class ScriptSync implements vscode.Disposable { //#region Diagnostics public clearDiagnostics(): void { this.diagnosticSources.forEach((source) => { - this.diagnosticCollection.delete(vscode.Uri.file(source)); + this.diagnosticCollection.delete(vscode.Uri.parse(source)); }); this.diagnosticSources.clear(); } public addDiagnostics(diagnosticsMap: { [source: string]: vscode.Diagnostic[] }): void { Object.entries(diagnosticsMap).forEach(([filePath, diagnostics]) => { - const fileUri = vscode.Uri.file(filePath); + const fileUri = vscode.Uri.parse(filePath); const oldList = this.diagnosticCollection.get(fileUri) || []; const newList = [...oldList, ...diagnostics]; - this.diagnosticSources.add(filePath) + this.diagnosticSources.add(filePath); this.diagnosticCollection.set(fileUri, newList); - console.log(`Displayed ${diagnostics.length} errors for ${path.basename(filePath)}`); + console.log(`Displayed ${diagnostics.length} errors for ${path.basename(fileUri.fsPath)}`); }); } @@ -244,7 +249,7 @@ export class ScriptSync implements vscode.Disposable { errors.forEach((error) => { let line = error.row; - let file = normalizePath(this.masterDocument.uri.fsPath); + let file: StringUri = vscodeUriToStringUri(this.masterDocument.uri); let document: vscode.TextDocument | undefined = this.masterDocument; if (this.lineMappings) { @@ -253,7 +258,7 @@ export class ScriptSync implements vscode.Disposable { line = mapping.line; file = mapping.source; document = vscode.workspace.textDocuments.find(doc => - normalizePath(doc.uri.fsPath) === mapping.source + uriEquals(vscodeUriToStringUri(doc.uri), mapping.source) ); } } @@ -340,7 +345,7 @@ export class ScriptSync implements vscode.Disposable { const errorMessage = `Runtime error on object ${message.object_name} (${message.object_id}): ${message.error}`; let line = message.line; - let file = normalizePath(this.masterDocument.uri.fsPath); + let file: StringUri = vscodeUriToStringUri(this.masterDocument.uri); let document: vscode.TextDocument | undefined = this.masterDocument; if (this.lineMappings) { @@ -349,7 +354,7 @@ export class ScriptSync implements vscode.Disposable { line = mapping.line; file = mapping.source; document = vscode.workspace.textDocuments.find(doc => - normalizePath(doc.uri.fsPath) === mapping.source + uriEquals(vscodeUriToStringUri(doc.uri), mapping.source) ); } } @@ -373,7 +378,7 @@ export class ScriptSync implements vscode.Disposable { ); diagnostic.source = `Second Life Runtime`; - const fileUri = vscode.Uri.file(file); + const fileUri = vscode.Uri.parse(file as string); this.diagnosticSources.add(file); this.diagnosticCollection.set(fileUri, [diagnostic]); @@ -407,7 +412,7 @@ export class ScriptSync implements vscode.Disposable { const languageConfig = this.getLanguageConfig(); preprocessorResult = await this.preprocessor.process( originalContent, - normalizePath(masterFilePath), + vscodeUriToStringUri(this.masterDocument.uri), languageConfig, ); diff --git a/src/server/nodehost.ts b/src/server/nodehost.ts index 378bd4d..b2ef78a 100644 --- a/src/server/nodehost.ts +++ b/src/server/nodehost.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { glob } from 'glob'; -import { HostInterface, NormalizedPath, normalizePath } from '../interfaces/hostinterface'; +import { HostInterface, StringUri, filePathToStringUri, stringUriToFilePath } from '../interfaces/hostinterface'; import { FullConfigInterface } from '../interfaces/configinterface'; import * as yaml from 'js-yaml'; import * as toml from '@iarna/toml'; @@ -37,7 +37,7 @@ function hasWildcard(p: string): boolean { return /[*?]/.test(p); } export class NodeHost implements HostInterface { public readonly config: FullConfigInterface; - private readonly roots: NormalizedPath[]; + private readonly roots: string[]; private readonly fs: typeof fs; private readonly log: Logger; @@ -46,7 +46,7 @@ export class NodeHost implements HostInterface { throw new Error('NodeHost requires at least one root directory'); } this.config = opts.config; - this.roots = opts.roots.map(r => normalizePath(path.resolve(r))); + this.roots = opts.roots.map(r => path.normalize(path.resolve(r))); this.fs = opts.fsModule || fs; const noOp = (): void => {}; this.log = { @@ -57,23 +57,29 @@ export class NodeHost implements HostInterface { }; } - existsInSameWorkspace(_knownPath: string, _desiredPath: string): Promise { + existsInSameWorkspace(_knownUri: StringUri, _desiredPath: string): Promise { throw new Error('Method not implemented.'); } // --------------------------------------------------------------------- - async exists(p: NormalizedPath, _unsafe?: boolean): Promise { + async exists(uri: StringUri, _unsafe?: boolean): Promise { + const p = stringUriToFilePath(uri); + if (!p) return false; try { const st = await this.fs.promises.stat(p); return st.isFile(); } catch { return false; } } - async readFile(p: NormalizedPath, _unsafe?: boolean): Promise { + async readFile(uri: StringUri, _unsafe?: boolean): Promise { + const p = stringUriToFilePath(uri); + if (!p) return null; try { return await this.fs.promises.readFile(p, 'utf8'); } catch { return null; } } - async writeFile(p: NormalizedPath, content: string | Uint8Array): Promise { + async writeFile(uri: StringUri, content: string | Uint8Array): Promise { + const p = stringUriToFilePath(uri); + if (!p) return false; try { await this.ensureDir(path.dirname(p)); await this.fs.promises.writeFile(p, content); @@ -84,43 +90,43 @@ export class NodeHost implements HostInterface { } } - async readJSON(p: NormalizedPath, unsafe?: boolean): Promise { - const txt = await this.readFile(p, unsafe); + async readJSON(uri: StringUri, unsafe?: boolean): Promise { + const txt = await this.readFile(uri, unsafe); if (txt == null) return null; try { return JSON.parse(txt) as T; } catch { return null; } } - async writeJSON(p: NormalizedPath, data: any, pretty: boolean = true): Promise { + async writeJSON(uri: StringUri, data: any, pretty: boolean = true): Promise { try { const serialized = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data); - return await this.writeFile(p, serialized); + return await this.writeFile(uri, serialized); } catch (err) { - this.log.error('writeJSON failed', p, err); + this.log.error('writeJSON failed', uri, err); return false; } } - async readYAML(p: NormalizedPath, unsafe?: boolean): Promise { - const txt = await this.readFile(p, unsafe); + async readYAML(uri: StringUri, unsafe?: boolean): Promise { + const txt = await this.readFile(uri, unsafe); if (txt == null) return null; try { return yaml.load(txt) as T; } catch { return null; } } - async writeYAML(p: NormalizedPath, data: any): Promise { - try { return await this.writeFile(p, yaml.dump(data)); } catch { return false; } + async writeYAML(uri: StringUri, data: any): Promise { + try { return await this.writeFile(uri, yaml.dump(data)); } catch { return false; } } - async readTOML(p: NormalizedPath, unsafe?: boolean): Promise { - const txt = await this.readFile(p, unsafe); + async readTOML(uri: StringUri, unsafe?: boolean): Promise { + const txt = await this.readFile(uri, unsafe); if (txt == null) return null; try { return toml.parse(txt) as T; } catch { return null; } } - async writeTOML(p: NormalizedPath, data: Record): Promise { - try { return await this.writeFile(p, toml.stringify(data)); } catch { return false; } + async writeTOML(uri: StringUri, data: Record): Promise { + try { return await this.writeFile(uri, toml.stringify(data)); } catch { return false; } } - async listWorkspaceFolders(): Promise { return this.roots; } + async listWorkspaceFolders(): Promise { return this.roots.map(r => filePathToStringUri(r)); } isExtensionAvailable(_id: string): boolean { return false; } @@ -128,10 +134,12 @@ export class NodeHost implements HostInterface { /** Resolve include/require file akin to VSCode host but purely on filesystem. */ async resolveFile( filename: string, - from: NormalizedPath, + fromUri: StringUri, extensions: string[], includePaths?: string[] - ): Promise { + ): Promise { + const from = stringUriToFilePath(fromUri); + if (!from) return null; const fromDir = path.dirname(from); const hasExt = path.extname(filename).length > 0; const candidateExts = hasExt ? [''] : extensions.map(e => e.startsWith('.') ? e : `.${e}`); @@ -154,11 +162,11 @@ export class NodeHost implements HostInterface { if (isExplicitRelative && !candidateDirs.includes(fromDir)) candidateDirs.unshift(fromDir); const containsPath = /[\\/]/.test(filename); - const tryCandidate = async (absPath: string): Promise => { + const tryCandidate = async (absPath: string): Promise => { if (!this.isInsideRoots(absPath)) return null; try { const st = await this.fs.promises.stat(absPath); - if (st.isFile()) return normalizePath(absPath); + if (st.isFile()) return filePathToStringUri(absPath); } catch { /* ignore */ } return null; }; @@ -213,40 +221,6 @@ export class NodeHost implements HostInterface { private async ensureDir(dir: string): Promise { await this.fs.promises.mkdir(dir, { recursive: true }); } - - /** - * Convert a normalized file path to a file:// URI for NodeHost - */ - fileNameToUri(fileName: NormalizedPath): string { - // For NodeHost, we just use file:// URIs with absolute paths - const absPath = path.resolve(fileName); - // Convert Windows backslashes to forward slashes for URI - const uriPath = absPath.split(path.sep).join('/'); - // Ensure proper file:// URI format - return 'file:///' + (uriPath.startsWith('/') ? uriPath.slice(1) : uriPath); - } - - /** - * Convert a URI back to a normalized file path - */ - uriToFileName(uri: string): NormalizedPath { - // Handle file:// URIs - if (uri.startsWith('file:///')) { - let filePath = uri.substring('file:///'.length); - // On Windows, we need to handle drive letters - if (process.platform === 'win32' && /^[a-zA-Z]:/.test(filePath)) { - // Already has drive letter, just normalize - return normalizePath(filePath); - } - // On Unix or if missing drive letter on Windows, add leading slash - if (!filePath.startsWith('/')) { - filePath = '/' + filePath; - } - return normalizePath(filePath); - } - // If not a recognized URI scheme, treat as a path - return normalizePath(uri); - } } export default NodeHost; diff --git a/src/shared/conditionalprocessor.ts b/src/shared/conditionalprocessor.ts index f81c8eb..4b57f4e 100644 --- a/src/shared/conditionalprocessor.ts +++ b/src/shared/conditionalprocessor.ts @@ -10,7 +10,7 @@ import { Token, TokenType, type LanguageLexerConfig } from './lexer'; import type { MacroProcessor } from './macroprocessor'; import { PreprocessorDiagnostic, DiagnosticLocation, ErrorCodes } from './diagnostics'; -import { NormalizedPath } from '../interfaces/hostinterface'; +import { StringUri } from '../interfaces/hostinterface'; //#region Conditional State @@ -139,7 +139,7 @@ export class ConditionalProcessor { macroName: string, macros: MacroProcessor, line: number, - _sourceFile?: NormalizedPath, + _sourceFile?: StringUri, _column: number = 1 ): ConditionalResult { const condition = macros.isDefined(macroName); @@ -158,7 +158,7 @@ export class ConditionalProcessor { macroName: string, macros: MacroProcessor, line: number, - _sourceFile?: NormalizedPath, + _sourceFile?: StringUri, _column: number = 1 ): ConditionalResult { const condition = !macros.isDefined(macroName); @@ -177,7 +177,7 @@ export class ConditionalProcessor { tokens: Token[], macros: MacroProcessor, line: number, - _sourceFile?: NormalizedPath, + _sourceFile?: StringUri, _column: number = 1 ): ConditionalResult { const condition = this.evaluateCondition(tokens, macros); @@ -196,7 +196,7 @@ export class ConditionalProcessor { tokens: Token[], macros: MacroProcessor, line: number, - sourceFile?: NormalizedPath, + sourceFile?: StringUri, column: number = 1 ): ConditionalResult { if (this.stack.length === 0) { @@ -265,7 +265,7 @@ export class ConditionalProcessor { */ public processElse( line: number, - sourceFile?: NormalizedPath, + sourceFile?: StringUri, column: number = 1 ): ConditionalResult { if (this.stack.length === 0) { @@ -321,7 +321,7 @@ export class ConditionalProcessor { */ public processEndif( line: number, - sourceFile?: NormalizedPath, + sourceFile?: StringUri, column: number = 1 ): ConditionalResult { if (this.stack.length === 0) { diff --git a/src/shared/diagnostics.ts b/src/shared/diagnostics.ts index 33bff31..ddbac6d 100644 --- a/src/shared/diagnostics.ts +++ b/src/shared/diagnostics.ts @@ -4,7 +4,7 @@ * Copyright (C) 2025, Linden Research, Inc. */ -import { NormalizedPath } from "../interfaces/hostinterface"; +import { StringUri } from "../interfaces/hostinterface"; //#region Diagnostic Types @@ -26,7 +26,7 @@ export interface DiagnosticRelatedInfo { line: number; column: number; length: number; - sourceFile: NormalizedPath; + sourceFile: StringUri; } /** @@ -38,7 +38,7 @@ export interface PreprocessorDiagnostic { line: number; column: number; length: number; - sourceFile: NormalizedPath; + sourceFile: StringUri; code?: string; // Optional error code (e.g., "PP001") relatedInfo?: DiagnosticRelatedInfo[]; } @@ -50,7 +50,7 @@ export interface DiagnosticLocation { line: number; column: number; length: number; - sourceFile: NormalizedPath; + sourceFile: StringUri; } export class DiagnosticError extends Error { diff --git a/src/shared/includeprocessor.ts b/src/shared/includeprocessor.ts index 8d1d6ac..08ec278 100644 --- a/src/shared/includeprocessor.ts +++ b/src/shared/includeprocessor.ts @@ -10,7 +10,7 @@ * - File resolution via HostInterface */ -import { NormalizedPath, HostInterface, normalizeJoinPath, normalizePath } from '../interfaces/hostinterface'; +import { StringUri, HostInterface, UriSet, resolveUri, uriDirname, filePathToStringUri } from '../interfaces/hostinterface'; import { LanguageLexerConfig, Lexer, Token } from './lexer'; import { MacroProcessor } from './macroprocessor'; import { ConditionalProcessor } from './conditionalprocessor'; @@ -27,15 +27,15 @@ export interface IncludeResult { /** Parsed tokens from the included file */ tokens: Token[]; /** Resolved path of the included file */ - resolvedPath: NormalizedPath | null; + resolvedPath: StringUri | null; /** Error message if unsuccessful */ error?: string; /** Whether the file was included via an external alias */ external?: boolean; } -export type LuauRCRequireMap = {[k:NormalizedPath]:RequireMap}; -export type RequireMap = {[k:string]:NormalizedPath}; +export type LuauRCRequireMap = {[k: string]: RequireMap}; // Keys are StringUri at runtime +export type RequireMap = {[k: string]: StringUri}; export type LuauRCFile = { aliases: {[k:string]:string}; }; @@ -45,9 +45,9 @@ export type LuauRCFile = { */ export interface IncludeState { /** Files already included (for include guards) */ - includedFiles: Set; + includedFiles: UriSet; /** Current include stack (for circular detection) */ - includeStack: NormalizedPath[]; + includeStack: StringUri[]; /** Current include depth */ includeDepth: number; /** Maximum include depth allowed */ @@ -86,7 +86,7 @@ export class IncludeProcessor { */ public async processInclude( include: IncludeInfo, - sourceFile: NormalizedPath, + sourceFile: StringUri, state: IncludeState, _macros: MacroProcessor, _conditionals: ConditionalProcessor, @@ -333,7 +333,7 @@ export class IncludeProcessor { return filename; } - private async getLuauRequireAliasDir(requirePath:string, sourceFile:NormalizedPath, state:IncludeState) : Promise { + private async getLuauRequireAliasDir(requirePath:string, sourceFile:StringUri, state:IncludeState) : Promise { if(!requirePath.startsWith("@")) { throw "Alias must start with @"; } @@ -359,17 +359,17 @@ export class IncludeProcessor { throw `Require alias not found: ${requirePath}`; } - private async resolveLuaurcFileAliases(sourceFile:string, rcMap: LuauRCRequireMap) : Promise { + private async resolveLuaurcFileAliases(sourceFile: StringUri, rcMap: LuauRCRequireMap) : Promise { const map: RequireMap = {}; - let dir = normalizePath(sourceFile); + let dir: StringUri = sourceFile; let last = dir; let limit = 25; while(limit-- > 0) { - dir = normalizePath(path.dirname(dir)); + dir = uriDirname(dir); if(last == dir) { break; } - const norm = normalizeJoinPath(dir,".luaurc"); + const norm = resolveUri(dir,".luaurc"); let dirMap = rcMap[norm] ?? null; if(!dirMap) { const rcfile = await this.host.readJSON(norm); @@ -386,9 +386,9 @@ export class IncludeProcessor { for(const alias in aliases) { let str = aliases[alias]; if(path.isAbsolute(str)) { - rcMap[norm][alias] = normalizePath(str); + rcMap[norm][alias] = filePathToStringUri(str); } else { - rcMap[norm][alias] = normalizeJoinPath(dir,str); + rcMap[norm][alias] = resolveUri(dir, str); } } dirMap = rcMap[norm] ?? {}; @@ -406,7 +406,7 @@ export class IncludeProcessor { */ public static createState(maxIncludeDepth: number = 5, includePaths?: string[]): IncludeState { return { - includedFiles: new Set(), + includedFiles: new UriSet(), includeStack: [], includeDepth: 0, maxIncludeDepth, diff --git a/src/shared/languagerepository.ts b/src/shared/languagerepository.ts index a245c51..1861a4d 100644 --- a/src/shared/languagerepository.ts +++ b/src/shared/languagerepository.ts @@ -2,7 +2,7 @@ * @file languagerepository.ts * Copyright (C) 2025, Linden Research, Inc. */ -import { HostInterface, NormalizedPath, normalizeJoinPath } from '../interfaces/hostinterface'; +import { HostInterface, StringUri, resolveUri } from '../interfaces/hostinterface'; import { LanguageTransformer } from './languagetransformer'; import { JSONRPCInterface } from '../websockclient'; import { SyntaxCacheFile, SyntaxCacheGetRequest, SyntaxCacheList } from '../viewereditwsclient'; @@ -50,14 +50,14 @@ export class LanguageRepository { return syntax; } - public async getCachedSyntaxFileName(syntaxId: string): Promise { - let base: NormalizedPath; + public async getCachedSyntaxFileName(syntaxId: string): Promise { + let base: StringUri; if (!syntaxId || syntaxId === 'default') { - base = normalizeJoinPath(await this.host.config.getExtensionInstallPath(), 'data'); + base = resolveUri(await this.host.config.getExtensionInstallPath(), 'data'); } else { base = await this.host.config.getGlobalConfigPath(); } - return normalizeJoinPath(base, `syntax_def_${syntaxId}.json`); + return resolveUri(base, `syntax_def_${syntaxId}.json`); } public async hasCachedSyntaxFile(syntaxId: string): Promise { diff --git a/src/shared/languageservice.ts b/src/shared/languageservice.ts index 126999c..7430e53 100644 --- a/src/shared/languageservice.ts +++ b/src/shared/languageservice.ts @@ -7,8 +7,8 @@ import { JSONRPCInterface } from "../websockclient"; import { LanguageTransformer } from "./languagetransformer"; import { LanguageRepository } from "./languagerepository"; import { - NormalizedPath, - normalizeJoinPath, + StringUri, + resolveUri, HostInterface, TextDocLike, DisposableLike @@ -208,14 +208,14 @@ export class LanguageService implements DisposableLike { //#endregion //#region Language ID Caching utils - public async getCachedSyntaxFileName(syntaxId: string): Promise { - let base: NormalizedPath; + public async getCachedSyntaxFileName(syntaxId: string): Promise { + let base: StringUri; if (!syntaxId || syntaxId === "default") { - base = normalizeJoinPath(await this.host.config.getExtensionInstallPath(), "data"); + base = resolveUri(await this.host.config.getExtensionInstallPath(), "data"); } else { base = await this.host.config.getGlobalConfigPath(); } - return normalizeJoinPath(base, `syntax_def_${syntaxId}.json`); + return resolveUri(base, `syntax_def_${syntaxId}.json`); } public async hasCachedSyntaxFile(syntaxId: string): Promise { diff --git a/src/shared/lexer.ts b/src/shared/lexer.ts index 5db706e..294d3a6 100644 --- a/src/shared/lexer.ts +++ b/src/shared/lexer.ts @@ -7,7 +7,7 @@ */ import { ScriptLanguage } from "./languageservice"; -import { NormalizedPath } from "../interfaces/hostinterface"; +import { StringUri } from "../interfaces/hostinterface"; import { DiagnosticCollector, ErrorCodes } from "./diagnostics"; import { ConfigKey, FullConfigInterface } from "../interfaces/configinterface"; @@ -391,13 +391,13 @@ export class Lexer { private context: LexerContext; private tokens: Token[]; private config: BaseLanguageLexerConfig; - private sourceFile: NormalizedPath; + private sourceFile: StringUri; private diagnostics: DiagnosticCollector; constructor( source: string, languageConfig: BaseLanguageLexerConfig, - sourceFile?: NormalizedPath, + sourceFile?: StringUri, diagnostics?: DiagnosticCollector ) { this.source = source; @@ -411,7 +411,7 @@ export class Lexer { interpolatedStringDepth: [], }; this.tokens = []; - this.sourceFile = sourceFile || ("" as NormalizedPath); + this.sourceFile = sourceFile || ("" as StringUri); this.diagnostics = diagnostics || new DiagnosticCollector(); } diff --git a/src/shared/lexingpreprocessor.ts b/src/shared/lexingpreprocessor.ts index 3e48791..9c28d76 100644 --- a/src/shared/lexingpreprocessor.ts +++ b/src/shared/lexingpreprocessor.ts @@ -12,7 +12,7 @@ */ import { ScriptLanguage } from "./languageservice"; -import { NormalizedPath, HostInterface } from "../interfaces/hostinterface"; +import { StringUri, HostInterface } from "../interfaces/hostinterface"; import { FullConfigInterface, ConfigKey } from "../interfaces/configinterface"; import { LanguageLexerConfig, Lexer } from "./lexer"; import { IncludeInfo, Parser } from "./parser"; @@ -43,7 +43,7 @@ export interface PreprocessorError { message: string; lineNumber: number; columnNumber?: number; - file?: NormalizedPath; + file?: StringUri; isWarning: boolean; } @@ -122,7 +122,7 @@ export class LexingPreprocessor { */ public async process( source: string, - sourceFile: NormalizedPath, + sourceFile: StringUri, language: LanguageLexerConfig ): Promise { // Check if preprocessing is enabled @@ -145,7 +145,7 @@ export class LexingPreprocessor { const lexerDiagnostics = lexer.getDiagnostics(); // Get workspace roots if available - let workspaceRoots: NormalizedPath[] | undefined = undefined; + let workspaceRoots: StringUri[] | undefined = undefined; if (this.fs && this.fs.listWorkspaceFolders) { workspaceRoots = await this.fs.listWorkspaceFolders(); } diff --git a/src/shared/linemapper.ts b/src/shared/linemapper.ts index ef8ac3e..411f5cc 100644 --- a/src/shared/linemapper.ts +++ b/src/shared/linemapper.ts @@ -3,13 +3,13 @@ * Copyright (C) 2025, Linden Research, Inc. */ -import { HostInterface, NormalizedPath } from "../interfaces/hostinterface"; +import { HostInterface, StringUri } from "../interfaces/hostinterface"; import { ScriptLanguage } from "./languageservice"; //------------------------------------------------------------- export interface LineMapping { processedLine: number; - sourceFile: NormalizedPath; + sourceFile: StringUri; originalLine: number; } @@ -17,7 +17,7 @@ export interface LineMapping { export class LineMapper { - public static parseLineMappingsFromContent(content: string, language: ScriptLanguage = "lsl", host: HostInterface): LineMapping[] { + public static parseLineMappingsFromContent(content: string, language: ScriptLanguage = "lsl", _host: HostInterface): LineMapping[] { const lines = content.split('\n'); const lineMappings: LineMapping[] = []; const commentPrefix = language === "lsl" ? "// @line" : "-- @line"; @@ -50,12 +50,12 @@ export class LineMapper { // console.log(`quoted is: ${sourceFileString} `); const processedLine = i + 1; // Line numbers are 1-based - // Convert URI to filename using host interface - const sourceFileAbsolute: NormalizedPath = host.uriToFileName(sourceFileString); - // console.log(`absolute is ${sourceFileAbsolute}`); + // Store URI directly from the @line directive + const sourceFileUri = sourceFileString as StringUri; + // console.log(`URI is ${sourceFileUri}`); lineMappings.push({ processedLine: processedLine, - sourceFile: sourceFileAbsolute, + sourceFile: sourceFileUri, originalLine: lineNumber }); } @@ -74,7 +74,7 @@ export class LineMapper { * @returns Object with source file URI and original line number, or null if not found */ public static convertAbsoluteLineToSource(lineMappings: LineMapping[], absoluteLine: number): { - source: NormalizedPath; + source: StringUri; line: number } | null { @@ -111,10 +111,10 @@ export class LineMapper { /** * Finds all line mappings that reference a specific source file * @param lineMappings - Array of line mappings to search - * @param sourceFile - The source file to find mappings for (normalized path) + * @param sourceFile - The source file to find mappings for (URI) * @returns Array of line mappings that reference the specified source file */ - public static findMappingsForSourceFile(lineMappings: LineMapping[], sourceFile: NormalizedPath): LineMapping[] { + public static findMappingsForSourceFile(lineMappings: LineMapping[], sourceFile: StringUri): LineMapping[] { return lineMappings.filter(mapping => mapping.sourceFile === sourceFile); } @@ -122,11 +122,11 @@ export class LineMapper { * Finds all processed line numbers that correspond to a specific line in a source file * This is useful when a single source line generates multiple output lines (e.g., macro expansion) * @param lineMappings - Array of line mappings to search - * @param sourceFile - The source file to search for (normalized path) + * @param sourceFile - The source file to search for (URI) * @param originalLine - The line number in the original source file * @returns Array of processed line numbers that map to the specified source location */ - public static findProcessedLines(lineMappings: LineMapping[], sourceFile: NormalizedPath, originalLine: number): number[] { + public static findProcessedLines(lineMappings: LineMapping[], sourceFile: StringUri, originalLine: number): number[] { return lineMappings .filter(mapping => mapping.sourceFile === sourceFile && mapping.originalLine === originalLine) .map(mapping => mapping.processedLine); diff --git a/src/shared/macroprocessor.ts b/src/shared/macroprocessor.ts index 8720a75..995d4f4 100644 --- a/src/shared/macroprocessor.ts +++ b/src/shared/macroprocessor.ts @@ -24,7 +24,7 @@ import { Token, TokenType } from './lexer'; import { DiagnosticCollector, ErrorCodes, DiagnosticSeverity } from './diagnostics'; -import { NormalizedPath } from '../interfaces/hostinterface'; +import { StringUri } from '../interfaces/hostinterface'; //#region Macro Definition @@ -151,7 +151,7 @@ export class MacroProcessor { public processDefined( tokens: Token[], diagnostics?: DiagnosticCollector, - sourceFile?: NormalizedPath, + sourceFile?: StringUri, line?: number ): Token[] { const result: Token[] = []; @@ -279,7 +279,7 @@ export class MacroProcessor { context?: MacroExpansionContext, expanding?: Set, diagnostics?: DiagnosticCollector, - sourceFile?: NormalizedPath, + sourceFile?: StringUri, line?: number, column?: number ): Token[] | null { @@ -380,7 +380,7 @@ export class MacroProcessor { context?: MacroExpansionContext, expanding?: Set, diagnostics?: DiagnosticCollector, - sourceFile?: NormalizedPath, + sourceFile?: StringUri, line?: number, column?: number ): Token[] | null { @@ -450,7 +450,7 @@ export class MacroProcessor { context?: MacroExpansionContext, expanding?: Set, diagnostics?: DiagnosticCollector, - sourceFile?: NormalizedPath + sourceFile?: StringUri ): Token[] { const result: Token[] = []; const expandingSet = expanding || new Set(); diff --git a/src/shared/parser.ts b/src/shared/parser.ts index bc4696a..dd5dffa 100644 --- a/src/shared/parser.ts +++ b/src/shared/parser.ts @@ -6,10 +6,10 @@ * Consumes token stream from lexer and produces preprocessed output. */ -import * as path from 'path'; import { LanguageLexerConfig, Token, TokenType } from './lexer'; -import { NormalizedPath, HostInterface } from '../interfaces/hostinterface'; +import { StringUri, HostInterface, uriDirname } from '../interfaces/hostinterface'; import { FullConfigInterface, ConfigKey } from '../interfaces/configinterface'; +import { LineMapping } from './linemapper'; import type { DirectiveImplementations } from './lexingpreprocessor'; import { MacroProcessor, MacroExpansionContext } from './macroprocessor'; import { ConditionalProcessor } from './conditionalprocessor'; @@ -23,9 +23,9 @@ import { DiagnosticCollector, PreprocessorDiagnostic, ErrorCodes, DiagnosticErro */ export interface RequireState { /** Map of resolved file path to module ID */ - moduleMap: Map; + moduleMap: Map; /** Map of module ID to resolved file path */ - modulePathMap: Map; + modulePathMap: Map; /** Map of module ID to wrapped module tokens */ wrappedModules: Map; /** Next available module ID */ @@ -97,14 +97,7 @@ export interface ParserResult { success: boolean; } -/** - * Line mapping for source map generation - */ -export interface LineMapping { - processedLine: number; - originalLine: number; - sourceFile: NormalizedPath; -} +// LineMapping is imported from linemapper.ts /** * Information about an include directive @@ -151,7 +144,7 @@ export class Parser { private tokens: Token[]; private position: number; private state: ParserState; - private sourceFile: NormalizedPath; + private sourceFile: StringUri; private language: LanguageLexerConfig; // Output accumulators @@ -164,7 +157,7 @@ export class Parser { private currentOutputLine: number; private lastSourceLine: number; private lastSourceColumn: number; - private lastSourceFile: NormalizedPath; + private lastSourceFile: StringUri; private lineDirectiveEmittedForCurrentLine: boolean; private lastEmittedTokenType: TokenType | null; private lineCommentsDisabled: boolean; @@ -185,7 +178,7 @@ export class Parser { private isTopLevelParser: boolean; // Workspace roots for generating relative paths in @line directives - private workspaceRoots: NormalizedPath[]; + private workspaceRoots: StringUri[]; // Diagnostic collector private diagnostics: DiagnosticCollector; @@ -196,13 +189,13 @@ export class Parser { constructor( tokens: Token[], - sourceFile: NormalizedPath, + sourceFile: StringUri, language: LanguageLexerConfig, host?: HostInterface, directives?: DirectiveImplementations, initialState?: Partial, isTopLevel: boolean = true, - workspaceRoots?: NormalizedPath[], + workspaceRoots?: StringUri[], diagnostics?: DiagnosticCollector, config?: FullConfigInterface ) { @@ -221,7 +214,7 @@ export class Parser { this.diagnostics = diagnostics || new DiagnosticCollector(); // Default to file's directory as workspace root if not provided - this.workspaceRoots = workspaceRoots || [path.dirname(sourceFile) as NormalizedPath]; + this.workspaceRoots = workspaceRoots || [uriDirname(sourceFile)]; // Read configuration values for include processing from individual config keys const maxIncludeDepth = config?.getConfig(ConfigKey.PreprocessorMaxIncludeDepth) ?? 5; @@ -1247,11 +1240,10 @@ export class Parser { /** * Format a file path for use in @line directives. - * Attempts to make paths workspace-relative for portability and readability. - * Falls back to normalized absolute paths if file is outside workspace. + * Since we're using URIs throughout, just return the URI string. */ - private formatPathForLineDirective(absolutePath: NormalizedPath): string { - return this.host?.fileNameToUri(absolutePath) ?? ("file://" + absolutePath); + private formatPathForLineDirective(uri: StringUri): string { + return uri; } /** @@ -1894,7 +1886,7 @@ export class Parser { /** * Wrap module tokens in an anonymous function */ - private wrapModuleInFunction(moduleTokens: Token[], resolvedPath: NormalizedPath, lineNumber: number): Token[] { + private wrapModuleInFunction(moduleTokens: Token[], resolvedPath: StringUri, lineNumber: number): Token[] { const wrapped: Token[] = []; // Opening: (function() @@ -1948,7 +1940,7 @@ export class Parser { this.mappings = []; let outputLine = 1; - let currentSourceFile: NormalizedPath = this.sourceFile; + let currentSourceFile: StringUri = this.sourceFile; let currentSourceLine = 1; const lineDirectivePrefix = `${this.language.lineCommentPrefix} @line `; @@ -1963,7 +1955,8 @@ export class Parser { if (match) { currentSourceLine = parseInt(match[1], 10); - currentSourceFile = this.host?.uriToFileName(match[2]) ?? match[2] as NormalizedPath; + // URI is stored directly in the @line directive + currentSourceFile = match[2] as StringUri; } // Skip to next token (should be newline) @@ -2010,12 +2003,12 @@ export class Parser { * // Line 2 -> main.lsl:1 * // Line 4 -> math.lsl:3 */ - public static parseLineMappingsFromContent(content: string, language: LanguageLexerConfig, host: HostInterface): LineMapping[] { + public static parseLineMappingsFromContent(content: string, language: LanguageLexerConfig, _host: HostInterface): LineMapping[] { const lines = content.split('\n'); const lineMappings: LineMapping[] = []; const commentPrefix = `${language.lineCommentPrefix} @line`; - let currentSourceFile: NormalizedPath | null = null; + let currentSourceFile: StringUri | null = null; let currentSourceLine = 1; for (let i = 0; i < lines.length; i++) { @@ -2034,8 +2027,8 @@ export class Parser { const lineNumber = parseInt(match[1], 10); const sourceFileString = match[2]; - // Convert URI to normalized path using host - currentSourceFile = host.uriToFileName(sourceFileString); + // Store URI directly from the @line directive + currentSourceFile = sourceFileString as StringUri; currentSourceLine = lineNumber; } } else if (currentSourceFile) { diff --git a/src/shared/sharedutils.ts b/src/shared/sharedutils.ts index 8a7defd..47f9098 100644 --- a/src/shared/sharedutils.ts +++ b/src/shared/sharedutils.ts @@ -30,7 +30,7 @@ import * as fs from "fs"; import * as yaml from "js-yaml"; import * as TOML from "@iarna/toml"; -import { NormalizedPath, fileExists } from "../interfaces/hostinterface"; // migrated path abstractions +import { fileExists } from "../interfaces/hostinterface"; //============================================================================= //#region General Utilities @@ -69,7 +69,7 @@ export function fromJSON(jsonString: string): any | null { export async function writeJSONFile( obj: any, - filePath: NormalizedPath + filePath: string ): Promise { try { const jsonContent = toJSON(obj); @@ -86,7 +86,7 @@ export async function writeJSONFile( } } -export async function readJSONFile(filePath: NormalizedPath): Promise { +export async function readJSONFile(filePath: string): Promise { if (!(await fileExists(filePath))) { // File doesn't exist or can't be accessed @@ -131,7 +131,7 @@ export function fromYAML(yamlString: string): any | null { export async function writeYAMLFile( obj: any, - filePath: NormalizedPath, + filePath: string, options?: yaml.DumpOptions, ): Promise { try { @@ -149,7 +149,7 @@ export async function writeYAMLFile( } } -export async function readYAMLFile(filePath: NormalizedPath): Promise { +export async function readYAMLFile(filePath: string): Promise { if (!(await fileExists(filePath))) { // File doesn't exist or can't be accessed return null; @@ -188,7 +188,7 @@ export function fromTOML(tomlString: string): any | null { export async function writeTOMLFile( obj: any, - filePath: NormalizedPath, + filePath: string, ): Promise { try { const tomlContent = toTOML(obj); @@ -204,7 +204,7 @@ export async function writeTOMLFile( } } -export async function readTOMLFile(filePath: NormalizedPath): Promise { +export async function readTOMLFile(filePath: string): Promise { if (!(await fileExists(filePath))) { // File doesn't exist or can't be accessed return null; diff --git a/src/synchservice.ts b/src/synchservice.ts index 4ac65f8..30b4669 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -47,7 +47,7 @@ import { SL_SCHEME, SL_AUTHORITY } from "./vscode/objectcontentprovider"; type ParsedTempFile = { scriptName: string; scriptId: string; extension: string, language: ScriptLanguage }; export class SynchService implements vscode.Disposable { - // Tracks all active sync relationships between temp files and master files + // Tracks all active sync relationships, keyed by master file uri.toString() private activeSyncs: Map = new Map(); private context: vscode.ExtensionContext; private static instance: SynchService; @@ -99,11 +99,11 @@ export class SynchService implements vscode.Disposable { dispose(): void { // Dispose of all active script syncs - for (const [tempFilePath, scriptSync] of this.activeSyncs) { + for (const [masterUriKey, scriptSync] of this.activeSyncs) { try { scriptSync.dispose(); } catch (error) { - console.warn(`Error disposing sync for ${tempFilePath}:`, error); + console.warn(`Error disposing sync for ${masterUriKey}:`, error); } } this.activeSyncs.clear(); @@ -287,8 +287,7 @@ export class SynchService implements vscode.Disposable { this, ); await sync.initialize(); - const normalizedMasterPath = path.normalize(masterPath); - this.activeSyncs.set(normalizedMasterPath, sync); + this.activeSyncs.set(masterUri.toString(), sync); syncs.push(sync); } @@ -317,7 +316,7 @@ export class SynchService implements vscode.Disposable { return; } - this.activeSyncs.delete(sync.getMasterFilePath()); + this.activeSyncs.delete(sync.getMasterUri().toString()); this.syncedFileDecorator.refresh(sync.getMasterDocument().uri); sync.dispose(); @@ -437,7 +436,7 @@ export class SynchService implements vscode.Disposable { } const firstSync = this.activeSync ?? [...this.activeSyncs.values()][0]; - const scriptName = firstSync ? path.basename(firstSync.getMasterFilePath()) : undefined; + const scriptName = firstSync ? path.basename(firstSync.getMasterUri().fsPath) : undefined; const scriptLanguage = firstSync ? firstSync.getLanguage() : undefined; const response: SessionHandshakeResponse = { @@ -746,7 +745,7 @@ export class SynchService implements vscode.Disposable { public findSyncByMasterFilePath( masterFilePath: string, ): ScriptSync | undefined { - return this.activeSyncs.get(path.normalize(masterFilePath)); + return this.activeSyncs.get(vscode.Uri.file(masterFilePath).toString()); } public findSyncByIncludeFilePath( @@ -766,7 +765,7 @@ export class SynchService implements vscode.Disposable { viewerFile: vscode.TextDocument ): Promise { // Attempt to match by file meta info - const metaMatch = SynchService.findMasterFileByMetaComment(script, viewerFile); + const metaMatch = await SynchService.findMasterFileByMetaComment(script, viewerFile); if(metaMatch) return metaMatch; let files = await vscode.workspace.findFiles(`**/${script.scriptName}.${script.extension}`); @@ -948,13 +947,12 @@ export class SynchService implements vscode.Disposable { private onDeleteFiles(event: vscode.FileDeleteEvent): void { const uris = event.files; uris.forEach((uri) => { - const filePath = path.normalize(uri.fsPath); - this.removeSync(filePath); + this.removeSync(uri.fsPath); }); } private async onSaveTextDocument(document: vscode.TextDocument): Promise { - const filePath = path.normalize(document.fileName); + const filePath = document.uri.fsPath; const sync = this.findSyncByMasterFilePath(filePath); if (sync) { await sync.handleMasterSaved(); diff --git a/src/test/suite/conditional-diagnostics.test.ts b/src/test/suite/conditional-diagnostics.test.ts index 2367b54..0b61095 100644 --- a/src/test/suite/conditional-diagnostics.test.ts +++ b/src/test/suite/conditional-diagnostics.test.ts @@ -8,12 +8,12 @@ import { ConditionalProcessor } from '../../shared/conditionalprocessor'; import { MacroProcessor } from '../../shared/macroprocessor'; import { getLanguageConfig, Lexer, Token, TokenType } from '../../shared/lexer'; import { ErrorCodes, DiagnosticSeverity } from '../../shared/diagnostics'; -import { normalizePath } from '../../interfaces/hostinterface'; +import { filePathToStringUri } from '../../interfaces/hostinterface'; suite('ConditionalProcessor Diagnostics', () => { let conditionals: ConditionalProcessor; let macros: MacroProcessor; - const testFile = normalizePath('test.lsl'); + const testFile = filePathToStringUri('d:/test/test.lsl'); const lslLanguageConfig = getLanguageConfig('lsl'); /** diff --git a/src/test/suite/diagnostic-integration.test.ts b/src/test/suite/diagnostic-integration.test.ts index 00f3a84..19a2060 100644 --- a/src/test/suite/diagnostic-integration.test.ts +++ b/src/test/suite/diagnostic-integration.test.ts @@ -10,59 +10,11 @@ import * as assert from 'assert'; import { LexingPreprocessor, PreprocessorOptions } from '../../shared/lexingpreprocessor'; -import { HostInterface, NormalizedPath, normalizePath } from '../../interfaces/hostinterface'; -import { FullConfigInterface, ConfigKey } from '../../interfaces/configinterface'; +import { HostInterface, StringUri, filePathToStringUri } from '../../interfaces/hostinterface'; +import { ConfigKey } from '../../interfaces/configinterface'; +import { MockConfig } from './helpers/mockHost'; import { getLanguageConfig } from '../../shared/lexer'; -/** - * Mock configuration class for testing - */ -class MockConfig implements FullConfigInterface { - private configValues: Map = new Map(); - - constructor(initialValues?: Map) { - if (initialValues) { - this.configValues = new Map(initialValues); - } - } - - isEnabled(): boolean { - return true; - } - - getConfig(key: ConfigKey): T | undefined { - return this.configValues.get(key) as T | undefined; - } - - async setConfig(key: ConfigKey, value: T, scope?: any): Promise { - this.configValues.set(key, value); - } - - async getWorkspaceConfigPath(): Promise { - return normalizePath(""); - } - - async getGlobalConfigPath(): Promise { - return normalizePath(""); - } - - async getExtensionInstallPath(): Promise { - return normalizePath(""); - } - - getSessionValue(key: ConfigKey): T | undefined { - return undefined; - } - - setSessionValue(key: ConfigKey, value: T): void { - // No-op for tests - } - - useLocalConfig(): boolean { - return false; - } -} - /** * Create default preprocessor options for testing */ @@ -83,9 +35,9 @@ function createDefaultOptions(): PreprocessorOptions { * Create a mock host with in-memory file system for testing */ function createMockHostWithFiles(files: Map, options?: PreprocessorOptions): HostInterface { - const normalizedFiles = new Map(); + const normalizedFiles = new Map(); for (const [path, content] of files.entries()) { - normalizedFiles.set(normalizePath(path), content); + normalizedFiles.set(filePathToStringUri(path), content); } // Set up default preprocessor options if not provided @@ -102,20 +54,20 @@ function createMockHostWithFiles(files: Map, options?: Preproces return { config, - async readFile(path: NormalizedPath): Promise { + async readFile(path: StringUri): Promise { return normalizedFiles.get(path) ?? null; }, - async exists(path: NormalizedPath): Promise { + async exists(path: StringUri): Promise { return normalizedFiles.has(path); }, async resolveFile( filename: string, - from: NormalizedPath, + from: StringUri, extensions?: string[], includePaths?: string[] - ): Promise { + ): Promise { // Simple resolution: try exact path first - const exactPath = normalizePath(filename); + const exactPath = filePathToStringUri(filename); if (normalizedFiles.has(exactPath)) { return exactPath; } @@ -123,7 +75,7 @@ function createMockHostWithFiles(files: Map, options?: Preproces // Try with extensions if (extensions) { for (const ext of extensions) { - const withExt = normalizePath(filename + ext); + const withExt = filePathToStringUri(filename + ext); if (normalizedFiles.has(withExt)) { return withExt; } @@ -132,40 +84,29 @@ function createMockHostWithFiles(files: Map, options?: Preproces return null; }, - async writeFile(p: NormalizedPath, content: string | Uint8Array): Promise { + async writeFile(p: StringUri, content: string | Uint8Array): Promise { return false; }, - async readJSON(p: NormalizedPath): Promise { + async readJSON(p: StringUri): Promise { return null; }, - async readYAML(p: NormalizedPath): Promise { + async readYAML(p: StringUri): Promise { return null; }, - async readTOML(p: NormalizedPath): Promise { + async readTOML(p: StringUri): Promise { return null; }, - async writeJSON(p: NormalizedPath, data: any, pretty?: boolean): Promise { + async writeJSON(p: StringUri, data: any, pretty?: boolean): Promise { return false; }, - async writeYAML(p: NormalizedPath, data: any): Promise { + async writeYAML(p: StringUri, data: any): Promise { return false; }, - async writeTOML(p: NormalizedPath, data: Record): Promise { + async writeTOML(p: StringUri, data: Record): Promise { return false; }, async existsInSameWorkspace(knownPath: string, desiredPath: string): Promise { return false; - }, - fileNameToUri(fileName: NormalizedPath): string { - // Strip path to only include directories/filename after "test" directory - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - // Normalize backslashes to forward slashes - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - }, - uriToFileName(uri: string): NormalizedPath { - return normalizePath(uri.replace("unittest:///", "")); } }; @@ -186,7 +127,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -207,7 +148,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -228,7 +169,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -250,7 +191,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -279,7 +220,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -301,7 +242,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -324,7 +265,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -350,7 +291,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -370,14 +311,14 @@ default { state_entry() {} }`; // First run with error const errorResult = await preprocessor.process( `#elif`, - normalizePath('/test/first.lsl'), + filePathToStringUri('/test/first.lsl'), lslLanguageConfig ); // Second run without error const successResult = await preprocessor.process( `default { state_entry() {} }`, - normalizePath('/test/second.lsl'), + filePathToStringUri('/test/second.lsl'), lslLanguageConfig ); diff --git a/src/test/suite/helpers/expectMapping.ts b/src/test/suite/helpers/expectMapping.ts index 43cd02b..3466032 100644 --- a/src/test/suite/helpers/expectMapping.ts +++ b/src/test/suite/helpers/expectMapping.ts @@ -1,11 +1,11 @@ import * as assert from 'assert'; import { LineMapping } from '../../../shared/linemapper'; -import { NormalizedPath } from '../../../interfaces/hostinterface'; +import { StringUri } from '../../../interfaces/hostinterface'; /** * Assert that a single LineMapping matches expected values. */ -export function expectMapping(mapping: LineMapping | undefined, processed: number, original: number, file: NormalizedPath): void { +export function expectMapping(mapping: LineMapping | undefined, processed: number, original: number, file: StringUri): void { assert.ok(mapping, `Expected mapping for processed line ${processed}`); assert.strictEqual(mapping!.processedLine, processed, 'processedLine mismatch'); assert.strictEqual(mapping!.originalLine, original, 'originalLine mismatch'); @@ -16,7 +16,7 @@ export function expectMapping(mapping: LineMapping | undefined, processed: numbe * Assert multiple mappings compactly. * rows: Array of tuples [processedLine, originalLine, file] */ -export function expectMappings(mappings: LineMapping[], rows: Array<[number, number, NormalizedPath]>): void { +export function expectMappings(mappings: LineMapping[], rows: Array<[number, number, StringUri]>): void { assert.strictEqual(mappings.length, rows.length, `Expected ${rows.length} mappings, got ${mappings.length}`); for (let i = 0; i < rows.length; i++) { const [p, o, f] = rows[i]; diff --git a/src/test/suite/helpers/mockHost.ts b/src/test/suite/helpers/mockHost.ts new file mode 100644 index 0000000..7d9b377 --- /dev/null +++ b/src/test/suite/helpers/mockHost.ts @@ -0,0 +1,116 @@ +/** + * @file mockHost.ts + * Shared mock HostInterface and MockConfig factory for tests. + * Copyright (C) 2025, Linden Research, Inc. + */ + +import { HostInterface, StringUri, filePathToStringUri } from '../../../interfaces/hostinterface'; +import { FullConfigInterface, ConfigKey } from '../../../interfaces/configinterface'; + +// ─── MockConfig ─────────────────────────────────────────────────────────────── + +export class MockConfig implements FullConfigInterface { + private values: Map = new Map(); + + constructor(values?: Map | Partial>) { + if (values instanceof Map) { + this.values = new Map(values); + } else if (values) { + for (const [k, v] of Object.entries(values)) { + this.values.set(k as ConfigKey, v); + } + } + } + + isEnabled(): boolean { return true; } + + getConfig(key: ConfigKey): T | undefined; + getConfig(key: ConfigKey, defaultValue: T): T; + getConfig(key: ConfigKey, defaultValue?: T): T | undefined { + const value = this.values.get(key); + if (value !== undefined) { return value as T; } + return defaultValue; + } + + async setConfig(key: ConfigKey, value: T): Promise { this.values.set(key, value); } + async getWorkspaceConfigPath(): Promise { return filePathToStringUri('d:/test/config'); } + async getGlobalConfigPath(): Promise { return filePathToStringUri('d:/test/global'); } + async getExtensionInstallPath(): Promise { return filePathToStringUri('d:/test/extension'); } + getSessionValue(_key: ConfigKey): T | undefined { return undefined; } + setSessionValue(_key: ConfigKey, _value: T): void {} + useLocalConfig(): boolean { return false; } +} + +// ─── MockFileSystem ─────────────────────────────────────────────────────────── + +/** In-memory file system: maps StringUri → file content */ +export type MockFileSystem = Map; + +// ─── Factory functions ──────────────────────────────────────────────────────── + +/** + * Creates a no-op mock host. All reads return null/false. + * Optionally accepts a pre-built FullConfigInterface. + */ +export function createMockHost(config?: FullConfigInterface): HostInterface { + const cfg = config ?? new MockConfig(); + return { + config: cfg, + async readFile(_uri: StringUri): Promise { return null; }, + async exists(_uri: StringUri): Promise { return false; }, + async resolveFile(_f: string, _from: StringUri): Promise { return null; }, + async writeFile(_uri: StringUri, _content: string | Uint8Array): Promise { return false; }, + async readJSON(_uri: StringUri): Promise { return null; }, + async readYAML(_uri: StringUri): Promise { return null; }, + async readTOML(_uri: StringUri): Promise { return null; }, + async writeJSON(_uri: StringUri, _data: unknown): Promise { return false; }, + async writeYAML(_uri: StringUri, _data: unknown): Promise { return false; }, + async writeTOML(_uri: StringUri, _data: Record): Promise { return false; }, + async existsInSameWorkspace(_known: string, _desired: string): Promise { return false; }, + }; +} + +/** + * Creates a mock host backed by an in-memory file map. + * resolveFile performs a simple directory-relative lookup within the map. + * Files can be added/removed from the map after construction to simulate + * file system changes during a test. + */ +export function createMockHostWithFiles( + files: MockFileSystem, + config?: FullConfigInterface +): HostInterface { + const cfg = config ?? new MockConfig(); + return { + config: cfg, + async readFile(uri: StringUri): Promise { + return files.get(uri) ?? null; + }, + async exists(uri: StringUri): Promise { + return files.has(uri); + }, + async resolveFile(filename: string, from: StringUri): Promise { + // Strip the last path component to get the containing directory + const dir = from.replace(/\/[^/]*$/, '/'); + const candidate = (dir + filename) as StringUri; + return files.has(candidate) ? candidate : null; + }, + async writeFile(uri: StringUri, content: string | Uint8Array): Promise { + files.set(uri, typeof content === 'string' ? content : new TextDecoder().decode(content)); + return true; + }, + async readJSON(uri: StringUri): Promise { + const text = files.get(uri); + return text != null ? JSON.parse(text) as T : null; + }, + async readYAML(_uri: StringUri): Promise { return null; }, + async readTOML(_uri: StringUri): Promise { return null; }, + async writeJSON(uri: StringUri, data: unknown, pretty = true): Promise { + files.set(uri, JSON.stringify(data, null, pretty ? 2 : 0)); + return true; + }, + async writeYAML(_uri: StringUri, _data: unknown): Promise { return false; }, + async writeTOML(_uri: StringUri, _data: Record): Promise { return false; }, + async existsInSameWorkspace(_known: string, _desired: string): Promise { return false; }, + }; +} diff --git a/src/test/suite/include-diagnostics.test.ts b/src/test/suite/include-diagnostics.test.ts index 34fe558..ce59ce0 100644 --- a/src/test/suite/include-diagnostics.test.ts +++ b/src/test/suite/include-diagnostics.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import { IncludeProcessor, IncludeState } from '../../shared/includeprocessor'; import { DiagnosticCollector, DiagnosticSeverity, ErrorCodes } from '../../shared/diagnostics'; -import { NormalizedPath, HostInterface, normalizePath } from '../../interfaces/hostinterface'; +import { StringUri, HostInterface, filePathToStringUri } from '../../interfaces/hostinterface'; import { MacroProcessor } from '../../shared/macroprocessor'; import { ConditionalProcessor } from '../../shared/conditionalprocessor'; import { IncludeInfo } from '../../shared/parser'; @@ -31,7 +31,7 @@ const quickRequire = (file:string, line:number = 1) : IncludeInfo => { } suite('IncludeProcessor Diagnostics', () => { - const testFile = normalizePath("d:/test/main.lsl"); + const testFile = filePathToStringUri("d:/test/main.lsl"); let diagnostics: DiagnosticCollector; let macros: MacroProcessor; let conditionals: ConditionalProcessor; @@ -48,9 +48,9 @@ suite('IncludeProcessor Diagnostics', () => { test('should error when include file does not exist', async () => { // Given: A host that cannot find the file const host: Partial = { - readFile: async (_path: NormalizedPath) => null, - exists: async (_path: NormalizedPath) => false, - resolveFile: async (_filename: string, _from: NormalizedPath) => null + readFile: async (_path: StringUri) => null, + exists: async (_path: StringUri) => false, + resolveFile: async (_filename: string, _from: StringUri) => null }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -80,14 +80,14 @@ suite('IncludeProcessor Diagnostics', () => { test('should not error when file exists', async () => { // Given: A host that can find the file - const includePath = normalizePath("d:/test/lib.lsl"); + const includePath = filePathToStringUri("d:/test/lib.lsl"); const host: Partial = { - readFile: async (path: NormalizedPath) => { + readFile: async (path: StringUri) => { if (path === includePath) return '// Library code'; return null; }, - exists: async (path: NormalizedPath) => path === includePath, - resolveFile: async (filename: string, _from: NormalizedPath) => { + exists: async (path: StringUri) => path === includePath, + resolveFile: async (filename: string, _from: StringUri) => { if (filename === 'lib.lsl') return includePath; return null; } @@ -116,11 +116,11 @@ suite('IncludeProcessor Diagnostics', () => { suite('INC002: Circular Include', () => { test('should error on circular include', async () => { // Given: A file that's already in the include stack - const circularPath = normalizePath("d:/test/circular.lsl"); + const circularPath = filePathToStringUri("d:/test/circular.lsl"); const host: Partial = { - readFile: async (_path: NormalizedPath) => '// Content', - exists: async (_path: NormalizedPath) => true, - resolveFile: async (_filename: string, _from: NormalizedPath) => circularPath + readFile: async (_path: StringUri) => '// Content', + exists: async (_path: StringUri) => true, + resolveFile: async (_filename: string, _from: StringUri) => circularPath }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -151,11 +151,11 @@ suite('IncludeProcessor Diagnostics', () => { test('should allow include when not circular', async () => { // Given: A normal include scenario - const includePath = normalizePath("d:/test/normal.lsl"); + const includePath = filePathToStringUri("d:/test/normal.lsl"); const host: Partial = { - readFile: async (_path: NormalizedPath) => '// Content', - exists: async (_path: NormalizedPath) => true, - resolveFile: async (_filename: string, _from: NormalizedPath) => includePath + readFile: async (_path: StringUri) => '// Content', + exists: async (_path: StringUri) => true, + resolveFile: async (_filename: string, _from: StringUri) => includePath }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -182,10 +182,10 @@ suite('IncludeProcessor Diagnostics', () => { test('should error when max depth exceeded', async () => { // Given: Include state at max depth const host: Partial = { - readFile: async (_path: NormalizedPath) => '// Content', - exists: async (_path: NormalizedPath) => true, - resolveFile: async (_filename: string, _from: NormalizedPath) => - normalizePath("d:/test/deep.lsl") + readFile: async (_path: StringUri) => '// Content', + exists: async (_path: StringUri) => true, + resolveFile: async (_filename: string, _from: StringUri) => + filePathToStringUri("d:/test/deep.lsl") }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -217,11 +217,11 @@ suite('IncludeProcessor Diagnostics', () => { test('should not error when below max depth', async () => { // Given: Include state below max depth - const includePath = normalizePath("d:/test/shallow.lsl"); + const includePath = filePathToStringUri("d:/test/shallow.lsl"); const host: Partial = { - readFile: async (_path: NormalizedPath) => '// Content', - exists: async (_path: NormalizedPath) => true, - resolveFile: async (_filename: string, _from: NormalizedPath) => includePath + readFile: async (_path: StringUri) => '// Content', + exists: async (_path: StringUri) => true, + resolveFile: async (_filename: string, _from: StringUri) => includePath }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -248,11 +248,11 @@ suite('IncludeProcessor Diagnostics', () => { suite('INC005: File Read Error', () => { test('should error when file cannot be read', async () => { // Given: A host that resolves the file but cannot read it - const errorPath = normalizePath("d:/test/unreadable.lsl"); + const errorPath = filePathToStringUri("d:/test/unreadable.lsl"); const host: Partial = { - readFile: async (_path: NormalizedPath) => null, // Read fails - exists: async (_path: NormalizedPath) => true, - resolveFile: async (_filename: string, _from: NormalizedPath) => errorPath + readFile: async (_path: StringUri) => null, // Read fails + exists: async (_path: StringUri) => true, + resolveFile: async (_filename: string, _from: StringUri) => errorPath }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -281,11 +281,11 @@ suite('IncludeProcessor Diagnostics', () => { test('should not error when file is readable', async () => { // Given: A host that can read the file - const readablePath = normalizePath("d:/test/readable.lsl"); + const readablePath = filePathToStringUri("d:/test/readable.lsl"); const host: Partial = { - readFile: async (_path: NormalizedPath) => '// Readable content', - exists: async (_path: NormalizedPath) => true, - resolveFile: async (_filename: string, _from: NormalizedPath) => readablePath + readFile: async (_path: StringUri) => '// Readable content', + exists: async (_path: StringUri) => true, + resolveFile: async (_filename: string, _from: StringUri) => readablePath }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -312,9 +312,9 @@ suite('IncludeProcessor Diagnostics', () => { test('should report each error separately', async () => { // Given: Multiple include attempts with different errors const host: Partial = { - readFile: async (_path: NormalizedPath) => null, - exists: async (_path: NormalizedPath) => false, - resolveFile: async (_filename: string, _from: NormalizedPath) => null + readFile: async (_path: StringUri) => null, + exists: async (_path: StringUri) => false, + resolveFile: async (_filename: string, _from: StringUri) => null }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -337,9 +337,9 @@ suite('IncludeProcessor Diagnostics', () => { suite('Documentation Tests', () => { test('IncludeProcessor exists and has basic functionality', async () => { const host: Partial = { - readFile: async (_path: NormalizedPath) => null, - exists: async (_path: NormalizedPath) => false, - resolveFile: async (_filename: string, _from: NormalizedPath) => null + readFile: async (_path: StringUri) => null, + exists: async (_path: StringUri) => false, + resolveFile: async (_filename: string, _from: StringUri) => null }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -362,12 +362,12 @@ suite('IncludeProcessor Diagnostics', () => { suite('Complex Circular Chains', () => { test('should detect A→B→C→A circular chain', async () => { // Given: Three files in a circular chain - const fileA = normalizePath("d:/test/a.lsl"); - const fileB = normalizePath("d:/test/b.lsl"); - const fileC = normalizePath("d:/test/c.lsl"); + const fileA = filePathToStringUri("d:/test/a.lsl"); + const fileB = filePathToStringUri("d:/test/b.lsl"); + const fileC = filePathToStringUri("d:/test/c.lsl"); const host: Partial = { - readFile: async (path: NormalizedPath) => { + readFile: async (path: StringUri) => { if (path === fileA) return '#include "b.lsl"\nstring a;'; if (path === fileB) return '#include "c.lsl"\nstring b;'; if (path === fileC) return '#include "a.lsl"\nstring c;'; @@ -410,11 +410,11 @@ suite('IncludeProcessor Diagnostics', () => { test('should detect A→B→A→C (circular earlier in chain)', async () => { // Given: Circular dependency that occurs before reaching end - const fileA = normalizePath("d:/test/a.lsl"); - const fileB = normalizePath("d:/test/b.lsl"); + const fileA = filePathToStringUri("d:/test/a.lsl"); + const fileB = filePathToStringUri("d:/test/b.lsl"); const host: Partial = { - readFile: async (path: NormalizedPath) => { + readFile: async (path: StringUri) => { if (path === fileA) return '#include "b.lsl"\nstring a;'; if (path === fileB) return '#include "a.lsl"\n#include "c.lsl"\nstring b;'; return null; @@ -446,13 +446,13 @@ suite('IncludeProcessor Diagnostics', () => { test('should allow diamond pattern (A→B, A→C, B→D, C→D)', async () => { // Given: Diamond dependency (not circular - D is included twice via different paths) - const fileA = normalizePath("d:/test/a.lsl"); - const fileB = normalizePath("d:/test/b.lsl"); - const fileC = normalizePath("d:/test/c.lsl"); - const fileD = normalizePath("d:/test/d.lsl"); + const fileA = filePathToStringUri("d:/test/a.lsl"); + const fileB = filePathToStringUri("d:/test/b.lsl"); + const fileC = filePathToStringUri("d:/test/c.lsl"); + const fileD = filePathToStringUri("d:/test/d.lsl"); const host: Partial = { - readFile: async (path: NormalizedPath) => { + readFile: async (path: StringUri) => { if (path === fileD) return 'string d;'; return '// content'; }, @@ -503,7 +503,7 @@ suite('IncludeProcessor Diagnostics', () => { const host: Partial = { readFile: async () => '// content', exists: async () => true, - resolveFile: async (filename: string) => normalizePath(`d:/test/${filename}`) + resolveFile: async (filename: string) => filePathToStringUri(`d:/test/${filename}`) }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -534,9 +534,9 @@ suite('IncludeProcessor Diagnostics', () => { test('should track depth correctly with multiple branches', async () => { // Given: Tree structure with different branch depths - const fileA = normalizePath("d:/test/a.lsl"); - const fileB = normalizePath("d:/test/b.lsl"); - const fileC = normalizePath("d:/test/c.lsl"); + const fileA = filePathToStringUri("d:/test/a.lsl"); + const fileB = filePathToStringUri("d:/test/b.lsl"); + const fileC = filePathToStringUri("d:/test/c.lsl"); let readCount = 0; const host: Partial = { @@ -580,7 +580,7 @@ suite('IncludeProcessor Diagnostics', () => { const host: Partial = { readFile: async () => '// content', exists: async () => true, - resolveFile: async (filename: string) => normalizePath(`d:/test/${filename}`) + resolveFile: async (filename: string) => filePathToStringUri(`d:/test/${filename}`) }; const processor = new IncludeProcessor(lslLanguageConfig, host as HostInterface); @@ -602,13 +602,13 @@ suite('IncludeProcessor Diagnostics', () => { suite('Mixed Successful and Failed Includes', () => { test('should process valid includes after failed include', async () => { // Given: Mix of valid and invalid includes - const validFile = normalizePath("d:/test/valid.lsl"); + const validFile = filePathToStringUri("d:/test/valid.lsl"); const host: Partial = { - readFile: async (path: NormalizedPath) => { + readFile: async (path: StringUri) => { if (path === validFile) return '// valid content'; return null; }, - exists: async (path: NormalizedPath) => path === validFile, + exists: async (path: StringUri) => path === validFile, resolveFile: async (filename: string) => { if (filename === 'valid.lsl') return validFile; return null; // missing.lsl won't resolve @@ -636,11 +636,11 @@ suite('IncludeProcessor Diagnostics', () => { test('should collect different error types in single parse', async () => { // Given: Scenario with multiple error types - const circularFile = normalizePath("d:/test/circular.lsl"); - const unreadableFile = normalizePath("d:/test/unreadable.lsl"); + const circularFile = filePathToStringUri("d:/test/circular.lsl"); + const unreadableFile = filePathToStringUri("d:/test/unreadable.lsl"); const host: Partial = { - readFile: async (path: NormalizedPath) => { + readFile: async (path: StringUri) => { if (path === unreadableFile) return null; // Read fails return '// content'; }, @@ -649,7 +649,7 @@ suite('IncludeProcessor Diagnostics', () => { if (filename === 'missing.lsl') return null; // Not found if (filename === 'circular.lsl') return circularFile; if (filename === 'unreadable.lsl') return unreadableFile; - return normalizePath(`d:/test/${filename}`); + return filePathToStringUri(`d:/test/${filename}`); } }; @@ -710,16 +710,16 @@ suite('IncludeProcessor Diagnostics', () => { test('should handle partial success in nested includes', async () => { // Given: Nested include where inner include fails - const outerFile = normalizePath("d:/test/outer.lsl"); - const middleFile = normalizePath("d:/test/middle.lsl"); + const outerFile = filePathToStringUri("d:/test/outer.lsl"); + const middleFile = filePathToStringUri("d:/test/middle.lsl"); const host: Partial = { - readFile: async (path: NormalizedPath) => { + readFile: async (path: StringUri) => { if (path === outerFile) return '// outer content'; if (path === middleFile) return '// middle content'; return null; }, - exists: async (path: NormalizedPath) => + exists: async (path: StringUri) => path === outerFile || path === middleFile, resolveFile: async (filename: string) => { if (filename === 'outer.lsl') return outerFile; diff --git a/src/test/suite/include-disk-integration.test.ts b/src/test/suite/include-disk-integration.test.ts index 71e5d0e..577f5a1 100644 --- a/src/test/suite/include-disk-integration.test.ts +++ b/src/test/suite/include-disk-integration.test.ts @@ -7,11 +7,36 @@ import * as assert from 'assert'; import * as path from 'path'; import * as fs from 'fs'; import { LexingPreprocessor, PreprocessorOptions } from '../../shared/lexingpreprocessor'; -import { normalizePath, type NormalizedPath, type HostInterface } from '../../interfaces/hostinterface'; +import { filePathToStringUri, stringUriToFilePath, type StringUri, type HostInterface } from '../../interfaces/hostinterface'; import type { FullConfigInterface } from '../../interfaces/configinterface'; import { ConfigKey } from '../../interfaces/configinterface'; import { getLanguageConfig } from '../../shared/lexer'; +/** + * Normalize paths in preprocessor output for comparison with expected files. + * Replaces absolute file:// URIs with relative-style paths that match expected output. + * @param content - The preprocessor output content + * @param workspaceRoot - The workspace root path + * @returns Content with normalized paths + */ +function normalizePathsForComparison(content: string, workspaceRoot: string): string { + // Convert workspaceRoot to URI format for matching + const workspaceUri = filePathToStringUri(workspaceRoot); + // Extract the path portion after file:/// + const workspaceUriPath = workspaceUri.replace(/^file:\/\/\//, ''); + + // Replace absolute paths with relative-style paths matching expected output format + // The expected files use format like: file:///test/workspace/set_1/... + // We need to replace: file:///c:/Users/.../src/test/workspace/set_1/... + // with: file:///test/workspace/set_1/... + + const absolutePattern = new RegExp( + `file:///${workspaceUriPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\//g, '/')}`, + 'gi' + ); + return content.replace(absolutePattern, 'file:///test/workspace/set_1'); +} + /** * Test config implementation */ @@ -40,14 +65,14 @@ class TestConfig implements FullConfigInterface { return undefined; } async setConfig(key: ConfigKey, value: T, scope?: any): Promise {} - async getWorkspaceConfigPath(): Promise { - return normalizePath(""); + async getWorkspaceConfigPath(): Promise { + return filePathToStringUri("d:/test/config"); } - async getGlobalConfigPath(): Promise { - return normalizePath(""); + async getGlobalConfigPath(): Promise { + return filePathToStringUri("d:/test/global"); } - async getExtensionInstallPath(): Promise { - return normalizePath(""); + async getExtensionInstallPath(): Promise { + return filePathToStringUri("d:/test/extension"); } getSessionValue(key: ConfigKey): T | undefined { return undefined; @@ -63,25 +88,31 @@ class TestConfig implements FullConfigInterface { */ class DiskTestHost implements HostInterface { config: FullConfigInterface; - private workspaceRoot: NormalizedPath; + private workspaceRoot: string; + private workspaceRootUri: StringUri; constructor(workspaceRoot: string, options: PreprocessorOptions) { - this.workspaceRoot = normalizePath(workspaceRoot); + this.workspaceRoot = workspaceRoot; + this.workspaceRootUri = filePathToStringUri(workspaceRoot); this.config = new TestConfig(options); } - async readFile(filePath: NormalizedPath): Promise { + async readFile(filePath: StringUri): Promise { try { - const content = fs.readFileSync(filePath, 'utf-8'); + const fsPath = stringUriToFilePath(filePath); + if (!fsPath) return null; + const content = fs.readFileSync(fsPath, 'utf-8'); return content; } catch (err) { return null; } } - async exists(filePath: NormalizedPath): Promise { + async exists(filePath: StringUri): Promise { try { - return fs.existsSync(filePath); + const fsPath = stringUriToFilePath(filePath); + if (!fsPath) return false; + return fs.existsSync(fsPath); } catch { return false; } @@ -89,20 +120,24 @@ class DiskTestHost implements HostInterface { async resolveFile( filename: string, - from: NormalizedPath, + from: StringUri, extensions?: string[], includePaths?: string[] - ): Promise { + ): Promise { const exts = extensions || ['.lsl']; const paths = includePaths || ['./include/', 'include/', '.']; + // Convert URI to file path for path operations + const fromPath = stringUriToFilePath(from); + if (!fromPath) return null; + const fromDir = path.dirname(fromPath); + // Try relative to the current file first - const fromDir = path.dirname(from); for (const ext of exts) { const withExt = filename.endsWith(ext) ? filename : filename + ext; - const absolutePath = normalizePath(path.resolve(fromDir, withExt)); - if (await this.exists(absolutePath)) { - return absolutePath; + const absolutePath = path.resolve(fromDir, withExt); + if (fs.existsSync(absolutePath)) { + return filePathToStringUri(absolutePath); } } @@ -110,18 +145,18 @@ class DiskTestHost implements HostInterface { for (const includePath of paths) { let searchDir: string; if (includePath.startsWith('./')) { - searchDir = path.join(path.dirname(from), includePath.slice(2)); + searchDir = path.join(fromDir, includePath.slice(2)); } else if (includePath === '.') { - searchDir = path.dirname(from); + searchDir = fromDir; } else { searchDir = path.join(this.workspaceRoot, includePath); } for (const ext of exts) { const withExt = filename.endsWith(ext) ? filename : filename + ext; - const absolutePath = normalizePath(path.resolve(searchDir, withExt)); - if (await this.exists(absolutePath)) { - return absolutePath; + const absolutePath = path.resolve(searchDir, withExt); + if (fs.existsSync(absolutePath)) { + return filePathToStringUri(absolutePath); } } } @@ -129,11 +164,11 @@ class DiskTestHost implements HostInterface { return null; } - async writeFile(p: NormalizedPath, content: string | Uint8Array): Promise { + async writeFile(p: StringUri, content: string | Uint8Array): Promise { return false; } - async readJSON(p: NormalizedPath): Promise { + async readJSON(p: StringUri): Promise { return null; } @@ -141,38 +176,25 @@ class DiskTestHost implements HostInterface { return false; } - async readYAML(p: NormalizedPath): Promise { + async readYAML(p: StringUri): Promise { return null; } - async readTOML(p: NormalizedPath): Promise { + async readTOML(p: StringUri): Promise { return null; } - async writeJSON(p: NormalizedPath, data: any, pretty?: boolean): Promise { + async writeJSON(p: StringUri, data: any, pretty?: boolean): Promise { return false; } - async writeYAML(p: NormalizedPath, data: any): Promise { + async writeYAML(p: StringUri, data: any): Promise { return false; } - async writeTOML(p: NormalizedPath, data: Record): Promise { + async writeTOML(p: StringUri, data: Record): Promise { return false; } - fileNameToUri(fileName: NormalizedPath): string { - // Strip path to only include directories/filename after "test" directory - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - // Normalize backslashes to forward slashes - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "file:///" + normalizedPath; - } - - uriToFileName(uri: string): NormalizedPath { - return normalizePath(uri.replace(/^file:\/\/\//, '/')); - } - } suite('LSL Include Directive Tests - Disk-based Integration', () => { @@ -205,16 +227,20 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { }); test('should process simple include chain (A->B->C) from disk files', async () => { - const testFile = normalizePath(path.join(workspaceRoot, 'test_include_chain.lsl')); + const testFilePath = path.join(workspaceRoot, 'test_include_chain.lsl'); + const testFile = filePathToStringUri(testFilePath); const expectedFile = path.join(workspaceRoot, 'test_include_chain_expected.lsl'); - const source = fs.readFileSync(testFile, 'utf-8'); + const source = fs.readFileSync(testFilePath, 'utf-8'); const expected = fs.readFileSync(expectedFile, 'utf-8'); const preprocessor = new LexingPreprocessor(host, host.config); const result = await preprocessor.process(source, testFile, lslLanguageConfig); + // Normalize paths for comparison (actual output has absolute URIs, expected has relative) + const normalizedContent = normalizePathsForComparison(result.content, workspaceRoot); + // Compare with expected output - assert.strictEqual(result.content, expected, 'Output should match expected file'); + assert.strictEqual(normalizedContent, expected, 'Output should match expected file'); // Verify no errors assert.ok(result.success, 'Processing should succeed'); @@ -222,16 +248,20 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { }); test('should handle diamond dependency (A->B,C; B->C) from disk files', async () => { - const testFile = normalizePath(path.join(workspaceRoot, 'test_include_diamond.lsl')); + const testFilePath = path.join(workspaceRoot, 'test_include_diamond.lsl'); + const testFile = filePathToStringUri(testFilePath); const expectedFile = path.join(workspaceRoot, 'test_include_diamond_expected.lsl'); - const source = fs.readFileSync(testFile, 'utf-8'); + const source = fs.readFileSync(testFilePath, 'utf-8'); const expected = fs.readFileSync(expectedFile, 'utf-8'); const preprocessor = new LexingPreprocessor(host, host.config); const result = await preprocessor.process(source, testFile, lslLanguageConfig); + // Normalize paths for comparison + const normalizedContent = normalizePathsForComparison(result.content, workspaceRoot); + // Compare with expected output - assert.strictEqual(result.content, expected, 'Output should match expected file'); + assert.strictEqual(normalizedContent, expected, 'Output should match expected file'); // Count occurrences of the add function - should only appear once due to include guards const addFunctionMatches = result.content.match(/float add\(float a, float b\)/g); @@ -243,16 +273,20 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { }); test('should handle multiple includes with include guards', async () => { - const testFile = normalizePath(path.join(workspaceRoot, 'test_include_multiple.lsl')); + const testFilePath = path.join(workspaceRoot, 'test_include_multiple.lsl'); + const testFile = filePathToStringUri(testFilePath); const expectedFile = path.join(workspaceRoot, 'test_include_multiple_expected.lsl'); - const source = fs.readFileSync(testFile, 'utf-8'); + const source = fs.readFileSync(testFilePath, 'utf-8'); const expected = fs.readFileSync(expectedFile, 'utf-8'); const preprocessor = new LexingPreprocessor(host, host.config); const result = await preprocessor.process(source, testFile, lslLanguageConfig); + // Normalize paths for comparison + const normalizedContent = normalizePathsForComparison(result.content, workspaceRoot); + // Compare with expected output - assert.strictEqual(result.content, expected, 'Output should match expected file'); + assert.strictEqual(normalizedContent, expected, 'Output should match expected file'); // Verify macros were expanded (PI should be replaced with 3.14159265) assert.ok(result.content.includes('3.14159265'), 'PI macro should be expanded to its value'); @@ -263,8 +297,9 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { }); test('should generate correct @line directives at column 0', async () => { - const testFile = normalizePath(path.join(workspaceRoot, 'test_include_chain.lsl')); - const source = fs.readFileSync(testFile, 'utf-8'); + const testFilePath = path.join(workspaceRoot, 'test_include_chain.lsl'); + const testFile = filePathToStringUri(testFilePath); + const source = fs.readFileSync(testFilePath, 'utf-8'); const preprocessor = new LexingPreprocessor(host, host.config); const result = await preprocessor.process(source, testFile, lslLanguageConfig); @@ -288,8 +323,9 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { }); test('should create accurate line mappings for included files', async () => { - const testFile = normalizePath(path.join(workspaceRoot, 'test_include_chain.lsl')); - const source = fs.readFileSync(testFile, 'utf-8'); + const testFilePath = path.join(workspaceRoot, 'test_include_chain.lsl'); + const testFile = filePathToStringUri(testFilePath); + const source = fs.readFileSync(testFilePath, 'utf-8'); const preprocessor = new LexingPreprocessor(host, host.config); const result = await preprocessor.process(source, testFile, lslLanguageConfig); @@ -309,8 +345,9 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { }); test('should respect maxIncludeDepth limit and stop processing on error', async () => { - const testFile = normalizePath(path.join(workspaceRoot, 'test_include_chain.lsl')); - const source = fs.readFileSync(testFile, 'utf-8'); + const testFilePath = path.join(workspaceRoot, 'test_include_chain.lsl'); + const testFile = filePathToStringUri(testFilePath); + const source = fs.readFileSync(testFilePath, 'utf-8'); const options = createDefaultOptions(); options.maxIncludeDepth = 1; // Only allow one level of includes @@ -334,9 +371,10 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { }); test('test switch case', async () => { - const testFile = normalizePath(path.join(workspaceRoot, 'test_switch.lsl')); + const testFilePath = path.join(workspaceRoot, 'test_switch.lsl'); + const testFile = filePathToStringUri(testFilePath); const expectedFile = path.join(workspaceRoot, 'test_switch_expected.lsl'); - const source = fs.readFileSync(testFile, 'utf-8'); + const source = fs.readFileSync(testFilePath, 'utf-8'); const expected = fs.readFileSync(expectedFile, 'utf-8'); const preprocessor = new LexingPreprocessor(host, host.config); @@ -344,8 +382,11 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { const result = await preprocessor.process(source, testFile, lslLanguageConfigWithSwitch); + // Normalize paths for comparison + const normalizedContent = normalizePathsForComparison(result.content, workspaceRoot); + // Compare with expected output - assert.strictEqual(result.content, expected, 'Output should match expected file'); + assert.strictEqual(normalizedContent, expected, 'Output should match expected file'); // Verify no errors assert.ok(result.success, 'Processing should succeed'); diff --git a/src/test/suite/lexer-diagnostics.test.ts b/src/test/suite/lexer-diagnostics.test.ts index fad52fc..45f6fe2 100644 --- a/src/test/suite/lexer-diagnostics.test.ts +++ b/src/test/suite/lexer-diagnostics.test.ts @@ -7,10 +7,10 @@ import * as assert from 'assert'; import { getLanguageConfig, Lexer, TokenType } from '../../shared/lexer'; import { DiagnosticCollector, DiagnosticSeverity, ErrorCodes } from '../../shared/diagnostics'; -import { NormalizedPath } from '../../interfaces/hostinterface'; +import { filePathToStringUri } from '../../interfaces/hostinterface'; suite('Lexer Diagnostics', () => { - const testFile = "test.lsl" as NormalizedPath; + const testFile = filePathToStringUri('d:/test/test.lsl'); const lslLanguageConfig = getLanguageConfig('lsl'); const luauLanguageConfig = getLanguageConfig('luau'); diff --git a/src/test/suite/lexingpreprocessor.test.ts b/src/test/suite/lexingpreprocessor.test.ts index c041aa3..931f680 100644 --- a/src/test/suite/lexingpreprocessor.test.ts +++ b/src/test/suite/lexingpreprocessor.test.ts @@ -15,63 +15,19 @@ import { CustomLanguageLexerConfig, } from "../../shared/lexer"; import { LexingPreprocessor, PreprocessorOptions } from "../../shared/lexingpreprocessor"; -import { normalizePath, HostInterface, NormalizedPath } from "../../interfaces/hostinterface"; -import { FullConfigInterface, ConfigKey } from "../../interfaces/configinterface"; +import { filePathToStringUri, stringUriToFilePath, HostInterface, StringUri } from "../../interfaces/hostinterface"; +import { ConfigKey } from "../../interfaces/configinterface"; +import { MockConfig } from './helpers/mockHost'; /** - * Mock configuration class for testing + * Convert PreprocessorOptions to a ConfigKey map for MockConfig */ -class MockConfig implements FullConfigInterface { - private configValues: Map = new Map(); - - constructor(optionsOrMap?: PreprocessorOptions | Map) { - if (optionsOrMap) { - if (optionsOrMap instanceof Map) { - this.configValues = new Map(optionsOrMap); - } else { - // Set individual config keys instead of PreprocessorOptions object - this.configValues.set(ConfigKey.PreprocessorEnable, optionsOrMap.enable); - this.configValues.set(ConfigKey.PreprocessorIncludePaths, optionsOrMap.includePaths ?? ['.']); - this.configValues.set(ConfigKey.PreprocessorMaxIncludeDepth, optionsOrMap.maxIncludeDepth ?? 5); - } - } - } - - isEnabled(): boolean { - return true; - } - - getConfig(key: ConfigKey): T | undefined { - return this.configValues.get(key) as T | undefined; - } - - async setConfig(key: ConfigKey, value: T, scope?: any): Promise { - this.configValues.set(key, value); - } - - async getWorkspaceConfigPath(): Promise { - return normalizePath(""); - } - - async getGlobalConfigPath(): Promise { - return normalizePath(""); - } - - async getExtensionInstallPath(): Promise { - return normalizePath(""); - } - - getSessionValue(key: ConfigKey): T | undefined { - return undefined; - } - - setSessionValue(key: ConfigKey, value: T): void { - // No-op for tests - } - - useLocalConfig(): boolean { - return false; - } +function optionsToConfigMap(options: PreprocessorOptions): Map { + const map = new Map(); + map.set(ConfigKey.PreprocessorEnable, options.enable); + map.set(ConfigKey.PreprocessorIncludePaths, options.includePaths ?? ['.']); + map.set(ConfigKey.PreprocessorMaxIncludeDepth, options.maxIncludeDepth ?? 5); + return map; } suite("Lexing Preprocessor Test Suite", () => { @@ -89,57 +45,46 @@ suite("Lexing Preprocessor Test Suite", () => { const preprocessorOptions = options || createDefaultOptions(); return new class implements HostInterface { - config: FullConfigInterface = new MockConfig(preprocessorOptions); + config = new MockConfig(optionsToConfigMap(preprocessorOptions)); - async readFile(path: NormalizedPath): Promise { + async readFile(path: StringUri): Promise { return null; } - async exists(path: NormalizedPath): Promise { + async exists(path: StringUri): Promise { return false; } async resolveFile( filename: string, - from: NormalizedPath, + from: StringUri, extensions?: string[], includePaths?: string[] - ): Promise { + ): Promise { return null; } - async writeFile(p: NormalizedPath, content: string | Uint8Array): Promise { + async writeFile(p: StringUri, content: string | Uint8Array): Promise { return false; } - async readJSON(p: NormalizedPath): Promise { + async readJSON(p: StringUri): Promise { return null; } - async readYAML(p: NormalizedPath): Promise { + async readYAML(p: StringUri): Promise { return null; } - async readTOML(p: NormalizedPath): Promise { + async readTOML(p: StringUri): Promise { return null; } - async writeJSON(p: NormalizedPath, data: any, pretty?: boolean): Promise { + async writeJSON(p: StringUri, data: any, pretty?: boolean): Promise { return false; } - async writeYAML(p: NormalizedPath, data: any): Promise { + async writeYAML(p: StringUri, data: any): Promise { return false; } - async writeTOML(p: NormalizedPath, data: Record): Promise { + async writeTOML(p: StringUri, data: Record): Promise { return false; } async existsInSameWorkspace(knownPath: string, desiredPath: string): Promise { return false; } - fileNameToUri(fileName: NormalizedPath): string { - // Strip path to only include directories/filename after "test" directory - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - // Normalize backslashes to forward slashes - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - } - uriToFileName(uri: string): NormalizedPath { - return normalizePath(uri.replace("unittest:///", "")); - } }; } @@ -148,64 +93,53 @@ suite("Lexing Preprocessor Test Suite", () => { */ function createMockHostWithFS( options: PreprocessorOptions | undefined, - readFileFn: (path: NormalizedPath) => Promise, - existsFn?: (path: NormalizedPath) => Promise, - resolveFileFn?: (filename: string, from: NormalizedPath, extensions?: string[], includePaths?: string[]) => Promise + readFileFn: (path: StringUri) => Promise, + existsFn?: (path: StringUri) => Promise, + resolveFileFn?: (filename: string, from: StringUri, extensions?: string[], includePaths?: string[]) => Promise ): HostInterface { const opts = options || createDefaultOptions(); return new class implements HostInterface { - config: FullConfigInterface = new MockConfig(opts); + config = new MockConfig(optionsToConfigMap(opts)); - async readFile(path: NormalizedPath): Promise { + async readFile(path: StringUri): Promise { return readFileFn(path); } - async exists(path: NormalizedPath): Promise { + async exists(path: StringUri): Promise { return existsFn ? existsFn(path) : false; } async resolveFile( filename: string, - from: NormalizedPath, + from: StringUri, extensions?: string[], includePaths?: string[] - ): Promise { + ): Promise { return resolveFileFn ? resolveFileFn(filename, from, extensions, includePaths) : null; } - async writeFile(p: NormalizedPath, content: string | Uint8Array): Promise { + async writeFile(p: StringUri, content: string | Uint8Array): Promise { return false; } - async readJSON(p: NormalizedPath): Promise { + async readJSON(p: StringUri): Promise { return null; } - async readYAML(p: NormalizedPath): Promise { + async readYAML(p: StringUri): Promise { return null; } - async readTOML(p: NormalizedPath): Promise { + async readTOML(p: StringUri): Promise { return null; } - async writeJSON(p: NormalizedPath, data: any, pretty?: boolean): Promise { + async writeJSON(p: StringUri, data: any, pretty?: boolean): Promise { return false; } - async writeYAML(p: NormalizedPath, data: any): Promise { + async writeYAML(p: StringUri, data: any): Promise { return false; } - async writeTOML(p: NormalizedPath, data: Record): Promise { + async writeTOML(p: StringUri, data: Record): Promise { return false; } async existsInSameWorkspace(knownPath: string, desiredPath: string): Promise { return false; } - fileNameToUri(fileName: NormalizedPath): string { - // Strip path to only include directories/filename after "test" directory - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - // Normalize backslashes to forward slashes - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - } - uriToFileName(uri: string): NormalizedPath { - return normalizePath(uri.replace("unittest:///", "")); - } }; } @@ -226,12 +160,30 @@ suite("Lexing Preprocessor Test Suite", () => { /** * Helper to normalize output for comparison - * Removes trailing spaces and collapses multiple blank lines + * Removes trailing spaces, collapses multiple blank lines, + * and normalizes @line directive URIs to a portable format */ function normalizeOutput(text: string): string { return text .split('\n') - .map(line => line.trimEnd()) // Remove trailing spaces + .map(line => { + // First, trim trailing spaces + let normalized = line.trimEnd(); + // Normalize @line directive URIs: replace any absolute file:// path + // with just the relative portion after "test/workspace/set_1/" + // or "out/test/workspace/set_1/" to match expected format + const lineMatch = normalized.match(/@line\s+\d+\s+"([^"]+)"/); + if (lineMatch) { + const uri = lineMatch[1]; + // Extract just the path portion after set_1/ + const set1Match = uri.match(/(?:test\/workspace\/set_1\/|out\/test\/workspace\/set_1\/)(.*)$/i); + if (set1Match) { + // Normalize to unittest:///test/workspace/set_1/... + normalized = normalized.replace(lineMatch[1], `unittest:///test/workspace/set_1/${set1Match[1]}`); + } + } + return normalized; + }) .join('\n') .replace(/\n{3,}/g, '\n\n'); // Collapse multiple blank lines to max 2 } @@ -1074,7 +1026,7 @@ suite("Lexing Preprocessor Test Suite", () => { test("should pass through simple code without directives", async () => { const source = `integer x = 42;`; - const sourceFile = normalizePath("test.lsl"); + const sourceFile = filePathToStringUri("d:/test/test.lsl"); const preprocessor = new LexingPreprocessor(mockHost, mockHost.config); const result = await preprocessor.process(source, sourceFile, lslLanguageConfig); @@ -1088,7 +1040,7 @@ suite("Lexing Preprocessor Test Suite", () => { test("should preserve comments", async () => { const source = `// Comment\ninteger x; /* block */`; - const sourceFile = normalizePath("test.lsl"); + const sourceFile = filePathToStringUri("d:/test/test.lsl"); const preprocessor = new LexingPreprocessor(mockHost, mockHost.config); const result = await preprocessor.process(source, sourceFile, lslLanguageConfig); @@ -1100,7 +1052,7 @@ suite("Lexing Preprocessor Test Suite", () => { test("should handle disabled preprocessing", async () => { const source = `#include "test.lsl"\ninteger x;`; - const sourceFile = normalizePath("test.lsl"); + const sourceFile = filePathToStringUri("d:/test/test.lsl"); const disabledOptions = { ...testOptions, enable: false }; const disabledHost = createMockHost(disabledOptions); @@ -1130,7 +1082,7 @@ default { const preprocessor = new LexingPreprocessor(mockHost, mockHost.config); const result = await preprocessor.process( source, - normalizePath("test.lsl"), + filePathToStringUri("d:/test/test.lsl"), lslLanguageConfig ); @@ -1156,7 +1108,7 @@ default { const preprocessor = new LexingPreprocessor(mockHost, mockHost.config); const result = await preprocessor.process( source, - normalizePath(sourceFile), + filePathToStringUri(sourceFile), lslLanguageConfig ); @@ -1227,7 +1179,7 @@ default { const preprocessor = new LexingPreprocessor(mockHost, mockHost.config); const result = await preprocessor.process( source, - normalizePath(sourceFile), + filePathToStringUri(sourceFile), lslLanguageConfig ); @@ -1281,7 +1233,7 @@ default { const mockHost = createMockHostWithFS( options, - async (path: NormalizedPath) => { + async (path: StringUri) => { // Simulate a chain of includes that exceeds depth 2 if (path.endsWith('file1.lsl')) { return '#include "file2.lsl"\ndefault { state_entry() {} }'; @@ -1292,8 +1244,12 @@ default { } return null; }, - async (path: NormalizedPath) => path.endsWith('.lsl'), - async (filename: string, from: NormalizedPath) => normalizePath(path.join(path.dirname(from), filename)) + async (p: StringUri) => p.endsWith('.lsl'), + async (filename: string, from: StringUri) => { + const fromPath = stringUriToFilePath(from); + if (!fromPath) return null; + return filePathToStringUri(path.join(path.dirname(fromPath), filename)); + } ); const preprocessor = new LexingPreprocessor(mockHost, mockHost.config); @@ -1301,7 +1257,7 @@ default { const source = '#include "file1.lsl"\ndefault { state_entry() {} }'; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1321,21 +1277,23 @@ default { const mockHost = createMockHostWithFS( options, - async (path: NormalizedPath): Promise => { - if (path.includes('include') && path.endsWith('utils.lsl')) { + async (p: StringUri): Promise => { + if (p.includes('include') && p.endsWith('utils.lsl')) { return 'integer MAGIC = 42;'; } return null; }, - async (path: NormalizedPath): Promise => { - return path.includes('include') && path.endsWith('utils.lsl'); + async (p: StringUri): Promise => { + return p.includes('include') && p.endsWith('utils.lsl'); }, - async (filename: string, from: NormalizedPath, extensions?: string[], includePaths?: string[]): Promise => { + async (filename: string, from: StringUri, extensions?: string[], includePaths?: string[]): Promise => { // Check if includePaths contains './include/' if (includePaths && includePaths.includes('./include/')) { - const dir = path.dirname(from); + const fromPath = stringUriToFilePath(from); + if (!fromPath) return null; + const dir = path.dirname(fromPath); const resolved = path.join(dir, 'include', filename); - return normalizePath(resolved); + return filePathToStringUri(resolved); } return null; } @@ -1345,7 +1303,7 @@ default { const source = '#include "utils.lsl"\ndefault { state_entry() { integer x = MAGIC; } }'; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1361,7 +1319,7 @@ default { const source = 'default { state_entry() {} }'; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1385,7 +1343,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1408,7 +1366,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1429,7 +1387,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1445,14 +1403,18 @@ default { state_entry() {} }`; const mockHost = createMockHostWithFS( options, - async (path: NormalizedPath) => { - if (path.endsWith('file1.lsl')) { + async (p: StringUri) => { + if (p.endsWith('file1.lsl')) { return 'integer x = 1;'; } return null; }, - async (path: NormalizedPath) => path.endsWith('.lsl'), - async (filename: string, from: NormalizedPath) => normalizePath(path.join(path.dirname(from), filename)) + async (p: StringUri) => p.endsWith('.lsl'), + async (filename: string, from: StringUri) => { + const fromPath = stringUriToFilePath(from); + if (!fromPath) return null; + return filePathToStringUri(path.join(path.dirname(fromPath), filename)); + } ); const preprocessor = new LexingPreprocessor(mockHost, mockHost.config); @@ -1460,7 +1422,7 @@ default { state_entry() {} }`; const source = '#include "file1.lsl"\ndefault { state_entry() {} }'; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1476,20 +1438,24 @@ default { state_entry() {} }`; const mockHost = createMockHostWithFS( options, - async (path: NormalizedPath) => { - if (path.endsWith('file1.lsl')) { + async (p: StringUri) => { + if (p.endsWith('file1.lsl')) { return '#include "file2.lsl"\ninteger a = 1;'; - } else if (path.endsWith('file2.lsl')) { + } else if (p.endsWith('file2.lsl')) { return '#include "file3.lsl"\ninteger b = 2;'; - } else if (path.endsWith('file3.lsl')) { + } else if (p.endsWith('file3.lsl')) { return '#include "file4.lsl"\ninteger c = 3;'; - } else if (path.endsWith('file4.lsl')) { + } else if (p.endsWith('file4.lsl')) { return 'integer d = 4;'; } return null; }, - async (path: NormalizedPath) => path.endsWith('.lsl'), - async (filename: string, from: NormalizedPath) => normalizePath(path.join(path.dirname(from), filename)) + async (p: StringUri) => p.endsWith('.lsl'), + async (filename: string, from: StringUri) => { + const fromPath = stringUriToFilePath(from); + if (!fromPath) return null; + return filePathToStringUri(path.join(path.dirname(fromPath), filename)); + } ); const preprocessor = new LexingPreprocessor(mockHost, mockHost.config); @@ -1497,7 +1463,7 @@ default { state_entry() {} }`; const source = '#include "file1.lsl"\ndefault { state_entry() {} }'; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1525,7 +1491,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1548,7 +1514,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1578,7 +1544,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1605,7 +1571,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfig ); @@ -1627,7 +1593,7 @@ default { state_entry() {} }`; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfigWithSwitch ); @@ -1659,7 +1625,7 @@ jump c7b43f; } @c7b43f; { -// @line 7 "unittest:///test/main.lsl" +// @line 7 "file:///test/main.lsl" print("Other"); jump sfcc97; } @@ -1671,7 +1637,7 @@ jump c7b43f; const result = await preprocessor.process( source, - normalizePath('/test/main.lsl'), + filePathToStringUri('/test/main.lsl'), lslLanguageConfigWithSwitch ); console.error("======================="); diff --git a/src/test/suite/line-mapping.test.ts b/src/test/suite/line-mapping.test.ts index 38c8027..a5bd828 100644 --- a/src/test/suite/line-mapping.test.ts +++ b/src/test/suite/line-mapping.test.ts @@ -5,12 +5,12 @@ import * as assert from 'assert'; import { LineMapper, LineMapping } from '../../shared/linemapper'; -import { normalizePath, NormalizedPath } from '../../interfaces/hostinterface'; +import { filePathToStringUri, StringUri } from '../../interfaces/hostinterface'; import { expectMapping } from './helpers/expectMapping'; suite('Line Mapping Tests', () => { // Helper function to create a mock URI - function np(p: string): NormalizedPath { return normalizePath(p); } + function np(p: string): StringUri { return filePathToStringUri(p); } // Helper function to create line mappings for testing function createLineMapping(processedLine: number, sourceFile: string, originalLine: number): LineMapping { diff --git a/src/test/suite/macro-diagnostics.test.ts b/src/test/suite/macro-diagnostics.test.ts index a0091d7..cec7b69 100644 --- a/src/test/suite/macro-diagnostics.test.ts +++ b/src/test/suite/macro-diagnostics.test.ts @@ -10,13 +10,13 @@ import * as assert from 'assert'; import { MacroProcessor, MacroExpansionContext } from '../../shared/macroprocessor'; import { getLanguageConfig, Lexer } from '../../shared/lexer'; import { DiagnosticCollector, DiagnosticSeverity, ErrorCodes } from '../../shared/diagnostics'; -import { normalizePath } from '../../interfaces/hostinterface'; +import { filePathToStringUri } from '../../interfaces/hostinterface'; suite('MacroProcessor Diagnostics', () => { let processor: MacroProcessor; let diagnostics: DiagnosticCollector; const lslLanguageConfig = getLanguageConfig('lsl'); - const testFile = normalizePath('test.lsl'); + const testFile = filePathToStringUri('d:/test/test.lsl'); setup(() => { processor = new MacroProcessor(); diff --git a/src/test/suite/nodehost.test.ts b/src/test/suite/nodehost.test.ts index 7de5c40..94389d2 100644 --- a/src/test/suite/nodehost.test.ts +++ b/src/test/suite/nodehost.test.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { describe, it, before, after } from 'mocha'; import { NodeHost } from '../../server/nodehost'; -import { normalizePath, NormalizedPath } from '../../interfaces/hostinterface'; +import { filePathToStringUri, StringUri } from '../../interfaces/hostinterface'; import { ConfigKey, FullConfigInterface } from '../../interfaces/configinterface'; // Minimal in-memory + passthrough config implementation for tests @@ -16,9 +16,9 @@ class TestConfig implements FullConfigInterface { } getConfig(key: ConfigKey): T | undefined { return this.data.get(key); } async setConfig(key: ConfigKey, value: T): Promise { this.data.set(key, value); } - getExtensionInstallPath(): Promise { return Promise.resolve(normalizePath(this.root)); } - getGlobalConfigPath(): Promise { return Promise.resolve(normalizePath(path.join(this.root, '.global'))); } - getWorkspaceConfigPath(): Promise { return Promise.resolve(normalizePath(path.join(this.root, '.workspace'))); } + getExtensionInstallPath(): Promise { return Promise.resolve(filePathToStringUri(this.root)); } + getGlobalConfigPath(): Promise { return Promise.resolve(filePathToStringUri(path.join(this.root, '.global'))); } + getWorkspaceConfigPath(): Promise { return Promise.resolve(filePathToStringUri(path.join(this.root, '.workspace'))); } getSessionValue(key: ConfigKey): T | undefined { return this.session.get(key); } setSessionValue(key: ConfigKey, value: T): void { this.session.set(key, value); } useLocalConfig(): boolean { return true; } @@ -42,7 +42,7 @@ describe('NodeHost', () => { }); it('writes and reads a file', async () => { - const file = normalizePath(path.join(tmpRoot, 'alpha.txt')); + const file = filePathToStringUri(path.join(tmpRoot, 'alpha.txt')); const ok = await host.writeFile(file, 'hello'); assert.strictEqual(ok, true); assert.strictEqual(await host.exists(file), true); @@ -51,14 +51,14 @@ describe('NodeHost', () => { }); it('reads and writes JSON', async () => { - const file = normalizePath(path.join(tmpRoot, 'data.json')); + const file = filePathToStringUri(path.join(tmpRoot, 'data.json')); await host.writeJSON(file, { a: 1 }); const obj = await host.readJSON(file); assert.deepStrictEqual(obj, { a: 1 }); }); it('reads and writes YAML', async () => { - const file = normalizePath(path.join(tmpRoot, 'config.yaml')); + const file = filePathToStringUri(path.join(tmpRoot, 'config.yaml')); const data = { foo: 'bar', n: 3 }; await host.writeYAML(file, data); const loaded = await host.readYAML(file); @@ -66,7 +66,7 @@ describe('NodeHost', () => { }); it('reads and writes TOML', async () => { - const file = normalizePath(path.join(tmpRoot, 'config.toml')); + const file = filePathToStringUri(path.join(tmpRoot, 'config.toml')); const data = { key: 'value', num: 42 }; await host.writeTOML(file, data); const loaded = await host.readTOML(file); @@ -74,9 +74,9 @@ describe('NodeHost', () => { }); it('resolves direct include in same directory', async () => { - const from = normalizePath(path.join(tmpRoot, 'main.lsl')); + const from = filePathToStringUri(path.join(tmpRoot, 'main.lsl')); await host.writeFile(from, '// main'); - const inc = normalizePath(path.join(tmpRoot, 'lib.lsl')); + const inc = filePathToStringUri(path.join(tmpRoot, 'lib.lsl')); await host.writeFile(inc, '// lib'); const resolved = await host.resolveFile('lib.lsl', from, ['.lsl'], ['.']); assert.strictEqual(resolved, inc); @@ -85,9 +85,9 @@ describe('NodeHost', () => { it('resolves include via relative include path', async () => { const subDir = path.join(tmpRoot, 'include'); await fs.promises.mkdir(subDir, { recursive: true }); - const from = normalizePath(path.join(tmpRoot, 'main2.lsl')); + const from = filePathToStringUri(path.join(tmpRoot, 'main2.lsl')); await host.writeFile(from, '// main2'); - const inc = normalizePath(path.join(subDir, 'util.lsl')); + const inc = filePathToStringUri(path.join(subDir, 'util.lsl')); await host.writeFile(inc, '// util'); const resolved = await host.resolveFile('util', from, ['.lsl'], ['include']); assert.strictEqual(resolved, inc); @@ -96,9 +96,9 @@ describe('NodeHost', () => { it('resolves include through wildcard pattern', async () => { const nested = path.join(tmpRoot, 'pkg', 'include'); await fs.promises.mkdir(nested, { recursive: true }); - const from = normalizePath(path.join(tmpRoot, 'pkg', 'main3.lsl')); + const from = filePathToStringUri(path.join(tmpRoot, 'pkg', 'main3.lsl')); await host.writeFile(from, '// main3'); - const inc = normalizePath(path.join(nested, 'wild.lsl')); + const inc = filePathToStringUri(path.join(nested, 'wild.lsl')); await host.writeFile(inc, '// wild'); const resolved = await host.resolveFile('wild', from, ['.lsl'], ['**/include/']); // On Windows drive letter casing may differ; compare case-insensitive diff --git a/src/test/suite/parse-line-mappings.test.ts b/src/test/suite/parse-line-mappings.test.ts index 73b6777..ad65e80 100644 --- a/src/test/suite/parse-line-mappings.test.ts +++ b/src/test/suite/parse-line-mappings.test.ts @@ -5,82 +5,25 @@ import * as assert from 'assert'; import { LineMapper, LineMapping } from '../../shared/linemapper'; -import { normalizePath, HostInterface, NormalizedPath } from '../../interfaces/hostinterface'; -import { FullConfigInterface } from '../../interfaces/configinterface'; +import { filePathToStringUri, StringUri } from '../../interfaces/hostinterface'; +import { createMockHost } from './helpers/mockHost'; import { expectMapping, expectMappings } from './helpers/expectMapping'; suite('Parse Line Mappings Tests', () => { // Helper function to create a mock URI - const np = (p: string): ReturnType => normalizePath(p); - - // Create a minimal mock host for testing URI conversions - function createMockHost(): HostInterface { - return new class implements HostInterface { - config = {} as FullConfigInterface; - - async readFile(path: NormalizedPath): Promise { - return null; - } - async exists(path: NormalizedPath): Promise { - return false; - } - async resolveFile( - filename: string, - from: NormalizedPath, - extensions?: string[], - includePaths?: string[] - ): Promise { - return null; - } - async writeFile(p: NormalizedPath, content: string | Uint8Array): Promise { - return false; - } - async readJSON(p: NormalizedPath): Promise { - return null; - } - async readYAML(p: NormalizedPath): Promise { - return null; - } - async readTOML(p: NormalizedPath): Promise { - return null; - } - async writeJSON(p: NormalizedPath, data: any, pretty?: boolean): Promise { - return false; - } - async writeYAML(p: NormalizedPath, data: any): Promise { - return false; - } - async writeTOML(p: NormalizedPath, data: Record): Promise { - return false; - } - async existsInSameWorkspace(knownPath: string, desiredPath: string): Promise { - return false; - } - fileNameToUri(fileName: NormalizedPath): string { - // Strip path to only include directories/filename after "test" directory - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - // Normalize backslashes to forward slashes - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - } - uriToFileName(uri: string): NormalizedPath { - return normalizePath(uri.replace("unittest:///", "")); - } - }; - } + const np = (p: string): ReturnType => filePathToStringUri(p); test('should parse LSL @line directives correctly', () => { const content = `// Processed by Second Life Script Preprocessor // Language: LSL // @ define: DEBUG=1 -// @line 0 "/path/to/main.lsl" +// @line 0 "file:///path/to/main.lsl" some code here more code -// @line 5 "/path/to/include.lsl" +// @line 5 "file:///path/to/include.lsl" included content another line -// @line 10 "/path/to/main.lsl" +// @line 10 "file:///path/to/main.lsl" back to main file`; const mappings = LineMapper.parseLineMappingsFromContent(content, "lsl", createMockHost()); @@ -96,14 +39,14 @@ back to main file`; const content = `-- Processed by Second Life Script Preprocessor -- Language: LUAU -- @ define: DEBUG=1 --- @line 0 "/path/to/main.luau" +-- @line 0 "file:///path/to/main.luau" local x = 1 print(x) --- @line 3 "/path/to/helper.luau" +-- @line 3 "file:///path/to/helper.luau" local function helper() return true end --- @line 7 "/path/to/main.luau" +-- @line 7 "file:///path/to/main.luau" local result = helper()`; const mappings = LineMapper.parseLineMappingsFromContent(content, "luau", createMockHost()); @@ -127,12 +70,12 @@ more code here`; test('should handle malformed @line directives gracefully', () => { const content = `// Good directive -// @line 5 "/path/to/file.lsl" +// @line 5 "file:///path/to/file.lsl" // Malformed directives // @line invalid "/path/to/file.lsl" // @line 10 // @line 15 "unclosed quote -// @line 20 "/valid/again.lsl" +// @line 20 "file:///valid/again.lsl" code here`; const mappings = LineMapper.parseLineMappingsFromContent(content, "lsl", createMockHost()); @@ -145,9 +88,9 @@ code here`; }); test('should handle mixed case and whitespace variations', () => { - const content = ` // @line 1 "/path/to/file.lsl" + const content = ` // @line 1 "file:///path/to/file.lsl" // @LINE 5 "/another/file.lsl" - // @line 10 "/tabs/file.lsl" + // @line 10 "file:///tabs/file.lsl" // @Line 15 "/mixed/case.lsl"`; const mappings = LineMapper.parseLineMappingsFromContent(content, "lsl", createMockHost()); @@ -160,7 +103,7 @@ code here`; }); test('should default to LSL when no language specified', () => { - const content = `// @line 1 "/path/to/file.lsl" + const content = `// @line 1 "file:///path/to/file.lsl" some code`; const mappings = LineMapper.parseLineMappingsFromContent(content, "lsl", createMockHost()); diff --git a/src/test/suite/parser-diagnostics.test.ts b/src/test/suite/parser-diagnostics.test.ts index 4a0763e..6751c19 100644 --- a/src/test/suite/parser-diagnostics.test.ts +++ b/src/test/suite/parser-diagnostics.test.ts @@ -7,14 +7,14 @@ import * as assert from 'assert'; import { Parser } from '../../shared/parser'; import { getLanguageConfig, Lexer } from '../../shared/lexer'; import { DiagnosticCollector, DiagnosticSeverity, ErrorCodes } from '../../shared/diagnostics'; -import { normalizePath, NormalizedPath } from '../../interfaces/hostinterface'; +import { filePathToStringUri, StringUri } from '../../interfaces/hostinterface'; suite('Parser Diagnostics Integration', () => { - let sourceFile: NormalizedPath; + let sourceFile: StringUri; const lslLanguageConfig = getLanguageConfig('lsl'); setup(() => { - sourceFile = normalizePath('test.lsl'); + sourceFile = filePathToStringUri('d:/test/test.lsl'); }); suite('Conditional Directive Errors', () => { @@ -168,7 +168,7 @@ code3 suite('Diagnostic Source File Tracking', () => { test('should track correct source file in diagnostics', async () => { - const testSource = normalizePath('custom.lsl'); + const testSource = filePathToStringUri('d:/test/custom.lsl'); const source = `#endif`; const diagnostics = new DiagnosticCollector(); const lexer = new Lexer(source, lslLanguageConfig, testSource, diagnostics); diff --git a/src/test/suite/parser-directive-diagnostics.test.ts b/src/test/suite/parser-directive-diagnostics.test.ts index e67c0b6..445e15b 100644 --- a/src/test/suite/parser-directive-diagnostics.test.ts +++ b/src/test/suite/parser-directive-diagnostics.test.ts @@ -7,14 +7,14 @@ import * as assert from 'assert'; import { Parser } from '../../shared/parser'; import { getLanguageConfig, Lexer, TokenType } from '../../shared/lexer'; import { DiagnosticCollector, DiagnosticSeverity, ErrorCodes } from '../../shared/diagnostics'; -import { normalizePath, NormalizedPath } from '../../interfaces/hostinterface'; +import { filePathToStringUri, StringUri } from '../../interfaces/hostinterface'; suite('Parser Directive Validation', () => { - let sourceFile: NormalizedPath; + let sourceFile: StringUri; const lslLanguageConfig = getLanguageConfig('lsl'); setup(() => { - sourceFile = normalizePath('test.lsl'); + sourceFile = filePathToStringUri('d:/test/test.lsl'); }); suite('PAR001: Malformed Directive', () => { diff --git a/src/test/suite/parser.test.ts b/src/test/suite/parser.test.ts index 61ddca2..040cf40 100644 --- a/src/test/suite/parser.test.ts +++ b/src/test/suite/parser.test.ts @@ -6,72 +6,15 @@ import * as assert from 'assert'; import { Parser } from '../../shared/parser'; import { getLanguageConfig, Lexer, TokenType } from '../../shared/lexer'; -import { normalizePath, HostInterface, NormalizedPath } from '../../interfaces/hostinterface'; -import { ConfigKey, FullConfigInterface } from '../../interfaces/configinterface'; +import { filePathToStringUri, StringUri, stringUriToFilePath } from '../../interfaces/hostinterface'; +import { createMockHost } from './helpers/mockHost'; suite('Parser Tests', () => { - const testFile = normalizePath('/test/script.lsl'); + const testFile = filePathToStringUri('/test/script.lsl'); const lslLanguageConfig = getLanguageConfig('lsl'); const luauLanguageConfig = getLanguageConfig('luau'); - // Create a minimal mock host for testing URI conversions - function createMockHost(): HostInterface { - return new class implements HostInterface { - config = {} as FullConfigInterface; - - async readFile(path: NormalizedPath): Promise { - return null; - } - async exists(path: NormalizedPath): Promise { - return false; - } - async resolveFile( - filename: string, - from: NormalizedPath, - extensions?: string[], - includePaths?: string[] - ): Promise { - return null; - } - async writeFile(p: NormalizedPath, content: string | Uint8Array): Promise { - return false; - } - async readJSON(p: NormalizedPath): Promise { - return null; - } - async readYAML(p: NormalizedPath): Promise { - return null; - } - async readTOML(p: NormalizedPath): Promise { - return null; - } - async writeJSON(p: NormalizedPath, data: any, pretty?: boolean): Promise { - return false; - } - async writeYAML(p: NormalizedPath, data: any): Promise { - return false; - } - async writeTOML(p: NormalizedPath, data: Record): Promise { - return false; - } - async existsInSameWorkspace(knownPath: string, desiredPath: string): Promise { - return false; - } - fileNameToUri(fileName: NormalizedPath): string { - // Strip path to only include directories/filename after "test" directory - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - // Normalize backslashes to forward slashes - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - } - uriToFileName(uri: string): NormalizedPath { - return normalizePath(uri.replace("unittest:///", "")); - } - }; - } - //#region Basic Parser Tests test('should handle simple pass-through code', async () => { @@ -979,25 +922,16 @@ float area = PI * r * r;`; // Create a mock host interface const mockHost = { config: {} as any, - resolveFile: async (filename: string): Promise => { - return filename === 'lib.lsl' ? normalizePath('/test/lib.lsl') : null; + resolveFile: async (filename: string): Promise => { + return filename === 'lib.lsl' ? filePathToStringUri('/test/lib.lsl') : null; }, readFile: async (path: any): Promise => { - return path === normalizePath('/test/lib.lsl') ? includeContent : null; + return path === filePathToStringUri('/test/lib.lsl') ? includeContent : null; }, exists: async (): Promise => true, writeFile: async (): Promise => true, readJSON: async (): Promise => null, writeJSON: async (): Promise => true, - fileNameToUri: (fileName: any): string => { - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - }, - uriToFileName: (uri: string): any => { - return normalizePath(uri.replace(/^unittest:\/\/\//, '/')); - }, }; const lexer = new Lexer(source, lslLanguageConfig); @@ -1035,30 +969,21 @@ float area = PI * r * r;`; let callCount = 0; const mockHost = { config: {} as any, - resolveFile: async (filename: string): Promise => { - if (filename === 'a.lsl') return normalizePath('/test/a.lsl'); - if (filename === 'b.lsl') return normalizePath('/test/b.lsl'); + resolveFile: async (filename: string): Promise => { + if (filename === 'a.lsl') return filePathToStringUri('/test/a.lsl'); + if (filename === 'b.lsl') return filePathToStringUri('/test/b.lsl'); return null; }, readFile: async (path: any): Promise => { callCount++; - if (path === normalizePath('/test/a.lsl')) return '#include "b.lsl"\nstring a = "a";'; - if (path === normalizePath('/test/b.lsl')) return '#include "a.lsl"\nstring b = "b";'; + if (path === filePathToStringUri('/test/a.lsl')) return '#include "b.lsl"\nstring a = "a";'; + if (path === filePathToStringUri('/test/b.lsl')) return '#include "a.lsl"\nstring b = "b";'; return null; }, exists: async (): Promise => true, writeFile: async (): Promise => true, readJSON: async (): Promise => null, writeJSON: async (): Promise => true, - fileNameToUri: (fileName: any): string => { - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - }, - uriToFileName: (uri: string): any => { - return normalizePath(uri.replace(/^unittest:\/\/\//, '/')); - }, }; const source = '#include "a.lsl"'; @@ -1082,11 +1007,11 @@ float area = PI * r * r;`; const mockHost = { config: {} as any, - resolveFile: async (filename: string): Promise => { - return filename === 'lib.lsl' ? normalizePath('/test/lib.lsl') : null; + resolveFile: async (filename: string): Promise => { + return filename === 'lib.lsl' ? filePathToStringUri('/test/lib.lsl') : null; }, readFile: async (path: any): Promise => { - if (path === normalizePath('/test/lib.lsl')) { + if (path === filePathToStringUri('/test/lib.lsl')) { readCount++; return libContent; } @@ -1096,15 +1021,6 @@ float area = PI * r * r;`; writeFile: async (): Promise => true, readJSON: async (): Promise => null, writeJSON: async (): Promise => true, - fileNameToUri: (fileName: any): string => { - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - }, - uriToFileName: (uri: string): any => { - return normalizePath(uri.replace(/^unittest:\/\/\//, '/')); - }, }; const source = `#include "lib.lsl" @@ -1129,7 +1045,7 @@ integer x = 1;`; // a.lsl -> b.lsl -> c.lsl -> d.lsl -> e.lsl -> f.lsl (6 levels, should fail) const mockHost = { config: {} as any, - resolveFile: async (filename: string): Promise => { + resolveFile: async (filename: string): Promise => { const fileMap: { [key: string]: string } = { 'a.lsl': '/test/a.lsl', 'b.lsl': '/test/b.lsl', @@ -1138,16 +1054,16 @@ integer x = 1;`; 'e.lsl': '/test/e.lsl', 'f.lsl': '/test/f.lsl', }; - return fileMap[filename] ? normalizePath(fileMap[filename]) : null; + return fileMap[filename] ? filePathToStringUri(fileMap[filename]) : null; }, readFile: async (path: any): Promise => { const contentMap: { [key: string]: string } = { - [normalizePath('/test/a.lsl')]: '#include "b.lsl"\nstring a = "a";', - [normalizePath('/test/b.lsl')]: '#include "c.lsl"\nstring b = "b";', - [normalizePath('/test/c.lsl')]: '#include "d.lsl"\nstring c = "c";', - [normalizePath('/test/d.lsl')]: '#include "e.lsl"\nstring d = "d";', - [normalizePath('/test/e.lsl')]: '#include "f.lsl"\nstring e = "e";', - [normalizePath('/test/f.lsl')]: 'string f = "f";', + [filePathToStringUri('/test/a.lsl') as string]: '#include "b.lsl"\nstring a = "a";', + [filePathToStringUri('/test/b.lsl') as string]: '#include "c.lsl"\nstring b = "b";', + [filePathToStringUri('/test/c.lsl') as string]: '#include "d.lsl"\nstring c = "c";', + [filePathToStringUri('/test/d.lsl') as string]: '#include "e.lsl"\nstring d = "d";', + [filePathToStringUri('/test/e.lsl') as string]: '#include "f.lsl"\nstring e = "e";', + [filePathToStringUri('/test/f.lsl') as string]: 'string f = "f";', }; return contentMap[path] || null; }, @@ -1155,15 +1071,6 @@ integer x = 1;`; writeFile: async (): Promise => true, readJSON: async (): Promise => null, writeJSON: async (): Promise => true, - fileNameToUri: (fileName: any): string => { - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - }, - uriToFileName: (uri: string): any => { - return normalizePath(uri.replace(/^unittest:\/\/\//, '/')); - }, }; const source = '#include "a.lsl"'; @@ -1234,8 +1141,8 @@ local y = 3.14`; }); test('parseLineMappingsFromContent - should handle multiple source files', () => { - const mainFile = normalizePath('/test/main.lsl'); - const includeFile = normalizePath('/test/include/math.lsl'); + const mainFile = filePathToStringUri('/test/main.lsl'); + const includeFile = filePathToStringUri('/test/include/math.lsl'); const content = `// @line 1 "${mainFile}" integer x = 1; diff --git a/src/test/suite/require-table.test.ts b/src/test/suite/require-table.test.ts index b9204cf..388ff62 100644 --- a/src/test/suite/require-table.test.ts +++ b/src/test/suite/require-table.test.ts @@ -14,56 +14,20 @@ import * as assert from 'assert'; import * as path from 'path'; import { Parser } from '../../shared/parser'; import { getLanguageConfig, Lexer } from '../../shared/lexer'; -import { HostInterface, NormalizedPath, normalizePath } from '../../interfaces/hostinterface'; +import { HostInterface, StringUri, filePathToStringUri, stringUriToFilePath } from '../../interfaces/hostinterface'; +import { createMockHostWithFiles } from './helpers/mockHost'; suite('Require Table Tests', () => { - const testFile = normalizePath('/test/main.luau'); + const testFile = filePathToStringUri('/test/main.luau'); const luauLanguageConfig = getLanguageConfig('luau'); - /** - * Create a minimal mock host for testing with in-memory files - */ - function createMockHost(files: Map): any { - return { - config: {} as any, - readFile: async (p: NormalizedPath): Promise => { - return files.get(p) || null; - }, - exists: async (p: NormalizedPath): Promise => { - return files.has(p); - }, - resolveFile: async ( - filename: string, - from: NormalizedPath, - extensions?: string[], - includePaths?: string[] - ): Promise => { - // Simple resolution: look in same directory as caller - const resolved = normalizePath(path.join(path.dirname(from), filename)); - return files.has(resolved) ? resolved : null; - }, - writeFile: async (): Promise => true, - readJSON: async (): Promise => null, - writeJSON: async (): Promise => true, - fileNameToUri: (fileName: NormalizedPath): string => { - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - }, - uriToFileName: (uri: string): NormalizedPath => { - return normalizePath(uri.replace(/^unittest:\/\/\//, '/')); - }, - }; - } - test('should wrap required module in function', async () => { - const moduleFile = normalizePath('/test/module.luau'); - const files = new Map(); + const moduleFile = filePathToStringUri('/test/module.luau'); + const files = new Map(); files.set(moduleFile, 'local x = 42\nreturn x'); files.set(testFile, 'local result = require("module.luau")'); - const host = createMockHost(files); + const host = createMockHostWithFiles(files); const lexer = new Lexer('local result = require("module.luau")', luauLanguageConfig); const tokens = lexer.tokenize(); @@ -77,12 +41,12 @@ suite('Require Table Tests', () => { }); test('should emit require function at file start', async () => { - const moduleFile = normalizePath('/test/module.luau'); - const files = new Map(); + const moduleFile = filePathToStringUri('/test/module.luau'); + const files = new Map(); files.set(moduleFile, 'local x = 42'); files.set(testFile, 'local result = require("module.luau")'); - const host = createMockHost(files); + const host = createMockHostWithFiles(files); const lexer = new Lexer('local result = require("module.luau")', luauLanguageConfig); const tokens = lexer.tokenize(); @@ -96,12 +60,12 @@ suite('Require Table Tests', () => { }); test('should invoke module from table at require point', async () => { - const moduleFile = normalizePath('/test/module.luau'); - const files = new Map(); + const moduleFile = filePathToStringUri('/test/module.luau'); + const files = new Map(); files.set(moduleFile, 'local x = 42'); files.set(testFile, 'local result = require("module.luau")'); - const host = createMockHost(files); + const host = createMockHostWithFiles(files); const lexer = new Lexer('local result = require("module.luau")', luauLanguageConfig); const tokens = lexer.tokenize(); @@ -114,12 +78,12 @@ suite('Require Table Tests', () => { }); test('should use same module ID for duplicate requires', async () => { - const moduleFile = normalizePath('/test/module.luau'); - const files = new Map(); + const moduleFile = filePathToStringUri('/test/module.luau'); + const files = new Map(); files.set(moduleFile, 'local x = 42'); files.set(testFile, 'require("module.luau")\nrequire("module.luau")'); - const host = createMockHost(files); + const host = createMockHostWithFiles(files); const source = 'require("module.luau")\nrequire("module.luau")'; const lexer = new Lexer(source, luauLanguageConfig); @@ -138,14 +102,14 @@ suite('Require Table Tests', () => { }); test('should assign different IDs to different modules', async () => { - const module1 = normalizePath('/test/module1.luau'); - const module2 = normalizePath('/test/module2.luau'); - const files = new Map(); + const module1 = filePathToStringUri('/test/module1.luau'); + const module2 = filePathToStringUri('/test/module2.luau'); + const files = new Map(); files.set(module1, 'local x = 1'); files.set(module2, 'local y = 2'); files.set(testFile, 'require("module1.luau")\nrequire("module2.luau")'); - const host = createMockHost(files); + const host = createMockHostWithFiles(files); const source = 'require("module1.luau")\nrequire("module2.luau")'; const lexer = new Lexer(source, luauLanguageConfig); @@ -164,12 +128,12 @@ suite('Require Table Tests', () => { }); test('should include @line directives in wrapped modules', async () => { - const moduleFile = normalizePath('/test/module.luau'); - const files = new Map(); + const moduleFile = filePathToStringUri('/test/module.luau'); + const files = new Map(); files.set(moduleFile, 'local x = 42'); files.set(testFile, 'require("module.luau")'); - const host = createMockHost(files); + const host = createMockHostWithFiles(files); const source = 'require("module.luau")'; const lexer = new Lexer(source, luauLanguageConfig); @@ -197,14 +161,14 @@ suite('Require Table Tests', () => { }); test('should handle nested requires (require within required module)', async () => { - const utilsFile = normalizePath('/test/utils.luau'); - const moduleFile = normalizePath('/test/module.luau'); - const files = new Map(); + const utilsFile = filePathToStringUri('/test/utils.luau'); + const moduleFile = filePathToStringUri('/test/module.luau'); + const files = new Map(); files.set(utilsFile, 'local function helper() end'); files.set(moduleFile, 'require("utils.luau")\nlocal x = 42'); files.set(testFile, 'require("module.luau")'); - const host = createMockHost(files); + const host = createMockHostWithFiles(files); const source = 'require("module.luau")'; const lexer = new Lexer(source, luauLanguageConfig); @@ -229,26 +193,31 @@ suite('Require Table Tests', () => { function createFileHost(rootDir: string): any { return { config: {} as any, - readFile: async (filePath: NormalizedPath): Promise => { + readFile: async (filePath: StringUri): Promise => { try { - return fs.readFileSync(filePath, 'utf8'); + const fsPath = stringUriToFilePath(filePath); + if (!fsPath) return null; + return fs.readFileSync(fsPath, 'utf8'); } catch { return null; } }, - exists: async (filePath: NormalizedPath): Promise => { - return fs.existsSync(filePath); + exists: async (filePath: StringUri): Promise => { + const fsPath = stringUriToFilePath(filePath); + if (!fsPath) return false; + return fs.existsSync(fsPath); }, resolveFile: async ( filename: string, - from: NormalizedPath, + from: StringUri, extensions?: string[], includePaths?: string[] - ): Promise => { - const fromDir = path.dirname(from); - const resolved = normalizePath(path.join(fromDir, filename)); - if (fs.existsSync(resolved)) { - return resolved; + ): Promise => { + const fromPath = stringUriToFilePath(from); + if (!fromPath) return null; + const resolvedPath = path.join(path.dirname(fromPath), filename); + if (fs.existsSync(resolvedPath)) { + return filePathToStringUri(resolvedPath); } return null; }, @@ -259,25 +228,17 @@ suite('Require Table Tests', () => { readTOML: async (): Promise => null, writeYAML: async (): Promise => true, writeTOML: async (): Promise => true, - fileNameToUri: (fileName: NormalizedPath): string => { - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - }, - uriToFileName: (uri: string): NormalizedPath => { - return normalizePath(uri.replace(/^unittest:\/\/\//, '/')); - }, }; } test('should handle nested requires (A->B->C->D) from disk files', async () => { - const mainFile = normalizePath(path.join(workspaceRoot, 'nested_a.luau')); + const mainPath = path.join(workspaceRoot, 'nested_a.luau'); + const mainFile = filePathToStringUri(mainPath); const host = createFileHost(workspaceRoot); // Read the main file - const source = fs.readFileSync(mainFile, 'utf8'); + const source = fs.readFileSync(mainPath, 'utf8'); const lexer = new Lexer(source, luauLanguageConfig); const tokens = lexer.tokenize(); @@ -313,12 +274,13 @@ suite('Require Table Tests', () => { }); test('should handle diamond dependency (A->B,D; B->D) from disk files', async () => { - const mainFile = normalizePath(path.join(workspaceRoot, 'diamond_a.luau')); + const mainPath = path.join(workspaceRoot, 'diamond_a.luau'); + const mainFile = filePathToStringUri(mainPath); const host = createFileHost(workspaceRoot); // Read the main file - const source = fs.readFileSync(mainFile, 'utf8'); + const source = fs.readFileSync(mainPath, 'utf8'); const lexer = new Lexer(source, luauLanguageConfig); const tokens = lexer.tokenize(); @@ -348,30 +310,33 @@ suite('Require Table Tests', () => { test('should handle complex nested diamond (A->B,C; B->D; C->D)', async () => { // Create test files for this scenario - const files = new Map(); + const files = new Map(); - const fileA = normalizePath(path.join(workspaceRoot, 'complex_a.luau')); - const fileB = normalizePath(path.join(workspaceRoot, 'complex_b.luau')); - const fileC = normalizePath(path.join(workspaceRoot, 'complex_c.luau')); - const fileD = normalizePath(path.join(workspaceRoot, 'complex_d.luau')); + const fileA = filePathToStringUri(path.join(workspaceRoot, 'complex_a.luau')); + const fileB = filePathToStringUri(path.join(workspaceRoot, 'complex_b.luau')); + const fileC = filePathToStringUri(path.join(workspaceRoot, 'complex_c.luau')); + const fileD = filePathToStringUri(path.join(workspaceRoot, 'complex_d.luau')); // Create a hybrid host that uses in-memory files const memoryHost : HostInterface = { config: {} as any, - readFile: async (p: NormalizedPath): Promise => { + readFile: async (p: StringUri): Promise => { return files.get(p) || null; }, - exists: async (p: NormalizedPath): Promise => { + exists: async (p: StringUri): Promise => { return files.has(p); }, resolveFile: async ( filename: string, - from: NormalizedPath, + from: StringUri, extensions?: string[], includePaths?: string[] - ): Promise => { - const fromDir = path.dirname(from); - const resolved = normalizePath(path.join(fromDir, filename)); + ): Promise => { + // Convert URI to path for path operations + const fromPath = stringUriToFilePath(from); + if (!fromPath) return null; + const resolvedPath = path.join(path.dirname(fromPath), filename); + const resolved = filePathToStringUri(resolvedPath); return files.has(resolved) ? resolved : null; }, writeFile: async (): Promise => true, @@ -381,18 +346,7 @@ suite('Require Table Tests', () => { readTOML: async (): Promise => null, writeYAML: async (): Promise => true, writeTOML: async (): Promise => true, - existsInSameWorkspace: async (knownPath: string, desiredPath: string): Promise => false, - fileNameToUri: (fileName: NormalizedPath): string => { - // Strip path to only include directories/filename after "test" directory - const testIndex = fileName.indexOf('test'); - const relativePath = testIndex !== -1 ? fileName.substring(testIndex) : fileName; - // Normalize backslashes to forward slashes - const normalizedPath = relativePath.replace(/\\/g, '/'); - return "unittest:///" + normalizedPath; - }, - uriToFileName: (uri: string): NormalizedPath => { - return normalizePath(uri.replace("unittest:///", "")); - } + existsInSameWorkspace: async (knownPath: string, desiredPath: string): Promise => false }; // Set up the complex diamond diff --git a/src/utils.ts b/src/utils.ts index e346133..8f4f67d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,7 +6,7 @@ import * as vscode from "vscode"; import path from "path"; import { ConfigService } from "./configservice"; import { ConfigKey, FullConfigInterface } from "./interfaces/configinterface"; -import { fileExists, HostInterface, NormalizedPath, normalizePath } from "./interfaces/hostinterface"; +import { fileExists, HostInterface, StringUri, filePathToStringUri, stringUriToFilePath } from "./interfaces/hostinterface"; import { writeJSONFile, readJSONFile, writeYAMLFile, writeTOMLFile, readYAMLFile, readTOMLFile } from "./shared/sharedutils"; // Generic utilities for sl-vscode-plugin @@ -126,10 +126,9 @@ export function showErrorMessage( //============================================================================= //#region Workspace Editor Utilities -export function closeEditor(documentFile: string): void { - documentFile = path.normalize(documentFile); +export function closeEditor(documentUri: vscode.Uri): void { const document = vscode.workspace.textDocuments.find( - (doc) => path.normalize(doc.fileName) === documentFile, + (doc) => doc.uri.toString() === documentUri.toString(), ); if (document) { vscode.window.showTextDocument(document).then((_editor) => { @@ -141,7 +140,7 @@ export function closeEditor(documentFile: string): void { export async function closeTextDocument( document: vscode.TextDocument, ): Promise { - const normalizedPath = path.normalize(document.fileName); + const docUriString = document.uri.toString(); // First try to find and close via tab groups const tabGroups = vscode.window.tabGroups.all; @@ -150,7 +149,7 @@ export async function closeTextDocument( for (const tab of tabGroup.tabs) { if ( tab.input instanceof vscode.TabInputText && - path.normalize(tab.input.uri.fsPath) === normalizedPath + tab.input.uri.toString() === docUriString ) { await vscode.window.tabGroups.close(tab); found = true; @@ -193,8 +192,50 @@ export async function uriExists(filePath: vscode.Uri): Promise { } } -export function uriToNormalizedPath(uri: vscode.Uri): NormalizedPath { - return normalizePath(uri.fsPath); +export function vscodeUriToStringUri(uri: vscode.Uri): StringUri { + // Prefer workspace:// URIs for files inside a workspace folder + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (folder) { + const relativePath = path.relative(folder.uri.fsPath, uri.fsPath).split(path.sep).join('/'); + return `workspace:///${folder.name}/${relativePath}` as StringUri; + } + return filePathToStringUri(uri.fsPath); +} + +export function stringUriToVscodeUri(uri: StringUri): vscode.Uri { + // Handle workspace:// scheme + if (uri.startsWith('workspace:///')) { + const withoutScheme = uri.substring('workspace:///'.length); + const slashIndex = withoutScheme.indexOf('/'); + + if (slashIndex === -1) { + throw new Error(`Invalid workspace URI: ${uri}`); + } + + const folderName = withoutScheme.substring(0, slashIndex); + const relativePath = withoutScheme.substring(slashIndex + 1); + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + throw new Error(`No workspace open for URI: ${uri}`); + } + + const folder = workspaceFolders.find(f => f.name === folderName); + if (!folder) { + throw new Error(`Workspace folder not found: ${folderName}`); + } + + return vscode.Uri.joinPath(folder.uri, relativePath); + } + + // Handle file:// URIs + const filePath = stringUriToFilePath(uri); + if (filePath) { + return vscode.Uri.file(filePath); + } + + // Fallback: parse as generic URI + return vscode.Uri.parse(uri); } export function errorLevelToSeverity(level: string): vscode.DiagnosticSeverity { @@ -226,9 +267,27 @@ export class VSCodeHost implements HostInterface { this.config = svc; } - async writeFile(filename: NormalizedPath, content: string | Uint8Array): Promise { + /** + * Convert an absolute filesystem path to a StringUri. + * Returns workspace:// URI for files inside a workspace folder, + * falling back to file:// for paths outside all workspace roots. + * This ensures @line directives never reveal true filesystem paths. + */ + private absPathToStringUri(absPath: string): StringUri { + const norm = path.normalize(absPath); + for (const folder of vscode.workspace.workspaceFolders || []) { + const folderPath = path.normalize(folder.uri.fsPath); + if (norm.toLowerCase().startsWith(folderPath.toLowerCase() + path.sep)) { + const relativePath = path.relative(folderPath, norm).split(path.sep).join('/'); + return `workspace:///${folder.name}/${relativePath}` as StringUri; + } + } + return filePathToStringUri(norm); + } + + async writeFile(filename: StringUri, content: string | Uint8Array): Promise { try { - const uri = vscode.Uri.file(filename); + const uri = stringUriToVscodeUri(filename); const data = typeof content === "string" ? Buffer.from(content, "utf8") : content; await vscode.workspace.fs.writeFile(uri, data); return true; @@ -237,47 +296,62 @@ export class VSCodeHost implements HostInterface { } } - async readJSON(p: NormalizedPath, _unsafe?: boolean): Promise { - return (await readJSONFile(p)) as T | null; + async readJSON(p: StringUri, _unsafe?: boolean): Promise { + const filePath = stringUriToFilePath(p); + if (!filePath) return null; + return (await readJSONFile(filePath)) as T | null; } - async writeJSON(p: NormalizedPath, data: any, _pretty: boolean = true): Promise { - return writeJSONFile(data, p); + async writeJSON(p: StringUri, data: any, _pretty: boolean = true): Promise { + const filePath = stringUriToFilePath(p); + if (!filePath) return false; + return writeJSONFile(data, filePath); } - async writeYAML(p: NormalizedPath, data: any): Promise { - return writeYAMLFile(data, p); + async writeYAML(p: StringUri, data: any): Promise { + const filePath = stringUriToFilePath(p); + if (!filePath) return false; + return writeYAMLFile(data, filePath); } - async writeTOML(p: NormalizedPath, data: Record): Promise { - return writeTOMLFile(data, p); + async writeTOML(p: StringUri, data: Record): Promise { + const filePath = stringUriToFilePath(p); + if (!filePath) return false; + return writeTOMLFile(data, filePath); } - async readYAML(p: NormalizedPath, _unsafe?: boolean): Promise { - return (await readYAMLFile(p)) as T | null; + async readYAML(p: StringUri, _unsafe?: boolean): Promise { + const filePath = stringUriToFilePath(p); + if (!filePath) return null; + return (await readYAMLFile(filePath)) as T | null; } - async readTOML(p: NormalizedPath, _unsafe?: boolean): Promise { - return (await readTOMLFile(p)) as T | null; + async readTOML(p: StringUri, _unsafe?: boolean): Promise { + const filePath = stringUriToFilePath(p); + if (!filePath) return null; + return (await readTOMLFile(filePath)) as T | null; } - async existsInSameWorkspace(knownPath: string, desiredPath: string): Promise { - const knownUri = vscode.Uri.file(normalizePath(knownPath)); - const workspaceDir = vscode.workspace.getWorkspaceFolder(knownUri); + async existsInSameWorkspace(knownUri: StringUri, desiredPath: string): Promise { + const vscodeKnownUri = stringUriToVscodeUri(knownUri); + const workspaceDir = vscode.workspace.getWorkspaceFolder(vscodeKnownUri); if(!workspaceDir) return false; - const desiredUri = vscode.Uri.file(normalizePath(workspaceDir.uri.fsPath + path.sep + desiredPath)); + const desiredUri = vscode.Uri.file(path.normalize(workspaceDir.uri.fsPath + path.sep + desiredPath)); const dWorkspaceDir = vscode.workspace.getWorkspaceFolder(desiredUri); if(!dWorkspaceDir) return false; return dWorkspaceDir.uri.fsPath == workspaceDir.uri.fsPath; } - async exists(filename: NormalizedPath, unsafe?: boolean): Promise { + async exists(filename: StringUri, unsafe?: boolean): Promise { + const filePath = stringUriToFilePath(filename); + if (!filePath) return false; + if (unsafe) { - return await fileExists(filename); + return await fileExists(filePath); } try { - const uri = vscode.Uri.file(filename); + const uri = stringUriToVscodeUri(filename); const folder = vscode.workspace.getWorkspaceFolder(uri); if (!folder) { return false; // Outside workspace @@ -289,25 +363,27 @@ export class VSCodeHost implements HostInterface { } } - async readFile(filepath: NormalizedPath, unsafe?: boolean): Promise { + async readFile(filepath: StringUri, unsafe?: boolean): Promise { if (!(await this.exists(filepath, unsafe))) { return null; } - const uri = vscode.Uri.file(filepath); + const uri = stringUriToVscodeUri(filepath); const document = await vscode.workspace.openTextDocument(uri); return document.getText(); } async resolveFile( filename: string, - from: NormalizedPath, + from: StringUri, extensions: string[], includePaths?: string[], unsafe: boolean = false, - ): Promise { - // Normalize base parameters - const normalizedFrom = path.normalize(from); - const fromDir = path.dirname(normalizedFrom); + ): Promise { + // Convert from StringUri to file path + const fromPath = stringUriToFilePath(from); + if (!fromPath) return null; + + const fromDir = path.dirname(fromPath); const hasExt = path.extname(filename).length > 0; const candidateExtensions = hasExt ? [""] : extensions.map(e => e.startsWith('.') ? e : `.${e}`); @@ -390,7 +466,7 @@ export class VSCodeHost implements HostInterface { const fullPath = ext === "" ? baseCandidate : baseCandidate + ext; const found = await tryCandidate(fullPath); if (found) { - return normalizePath(found); + return this.absPathToStringUri(found); } } } @@ -435,7 +511,7 @@ export class VSCodeHost implements HostInterface { if (matches.length > 0) { const candidate = path.normalize(matches[0].fsPath); if (await tryCandidate(candidate)) { - return normalizePath(candidate); + return this.absPathToStringUri(candidate); } } } catch { /* ignore */ } @@ -446,79 +522,9 @@ export class VSCodeHost implements HostInterface { return null; } - public fileNameToUri(fileName: NormalizedPath): string { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - // No workspace - return absolute file:// URL - return vscode.Uri.file(fileName).toString(); - } - - const relatives = []; - - // Find which workspace folder contains this file - for (const folder of workspaceFolders) { - const folderPath = folder.uri.fsPath; - if (fileName.startsWith(folderPath)) { - // File is inside this workspace folder - make it workspace-relative - const relativePath = path.relative(folderPath, fileName); - // Use forward slashes for URI consistency - const normalizedRelative = relativePath.split(path.sep).join('/'); - // Include folder name in URI to identify which workspace root - return `workspace:///${folder.name}/${normalizedRelative}`; - } else { - const relativePath = path.relative(folderPath, fileName); - if(relativePath.startsWith('..')) { - relatives.push(`workspace:///${folder.name}/${relativePath}`); - } - } - } - if(relatives.length > 0) { - relatives.sort((a,b) => a.length - b.length); - return relatives[0]; - } - - // return `workspace:///` + vscode.workspace.asRelativePath(fileName); - - // File is outside all workspace folders - return absolute file:// URL - return vscode.Uri.file(fileName).toString(); - } - - uriToFileName(uri: string): NormalizedPath { - // Handle workspace:// scheme - if (uri.startsWith('workspace:///')) { - const withoutScheme = uri.substring('workspace:///'.length); - const slashIndex = withoutScheme.indexOf('/'); - - if (slashIndex === -1) { - throw new Error(`Invalid workspace URI: ${uri}`); - } - - const folderName = withoutScheme.substring(0, slashIndex); - const relativePath = withoutScheme.substring(slashIndex + 1); - - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - throw new Error(`No workspace open for URI: ${uri}`); - } - - // Find the specific workspace folder by name - const folder = workspaceFolders.find(f => f.name === folderName); - if (!folder) { - throw new Error(`Workspace folder not found: ${folderName}`); - } - - const absolutePath = path.join(folder.uri.fsPath, relativePath); - // console.log(`uriToFileName: '${uri}' becomes '${absolutePath}'`); - return normalizePath(absolutePath); - } - // console.log(`uriToFileName: ${uri}`) - // Handle standard file:// URLs - return normalizePath(vscode.Uri.parse(uri).fsPath); - } - // Optional capability implementations ------------------------------------ - async listWorkspaceFolders(): Promise { - return (vscode.workspace.workspaceFolders || []).map(f => normalizePath(f.uri.fsPath)); + async listWorkspaceFolders(): Promise { + return (vscode.workspace.workspaceFolders || []).map(f => filePathToStringUri(f.uri.fsPath)); } isExtensionAvailable(id: string): boolean { From 80792d52c13d48599259e038a85b68deedd9c70c Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Wed, 1 Jul 2026 17:20:29 -0700 Subject: [PATCH 7/9] couple of issues found in cr. --- src/interfaces/hostinterface.ts | 43 +++++++++++++++------------------ src/utils.ts | 5 +++- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/interfaces/hostinterface.ts b/src/interfaces/hostinterface.ts index f2b3691..d5732e5 100644 --- a/src/interfaces/hostinterface.ts +++ b/src/interfaces/hostinterface.ts @@ -84,26 +84,29 @@ export function stringUriToFilePath(uri: StringUri): string | null { /** * Resolve a relative path against a base URI. - * Works for file:// and workspace:// URIs. + * Treats `base` as a directory URI and appends `relativePath` to it. + * Works for file:// and workspace:// URIs, preserving the scheme prefix exactly. + * Use uriDirname(base) first if base is a file URI and you want its parent directory. */ export function resolveUri(base: StringUri, relativePath: string): StringUri { - // Find the last slash to get directory - const lastSlash = base.lastIndexOf("/"); - if (lastSlash === -1) { - throw new Error(`Invalid base URI: ${base}`); - } + const normalizedRelative = relativePath.replace(/\\/g, "/"); - // Get base directory (everything up to last slash) - const baseDir = base.slice(0, lastSlash); + // Treat base as a directory; strip any trailing slash before appending + const baseDir = base.endsWith("/") ? base.slice(0, -1) : base as string; + const full = `${baseDir}/${normalizedRelative}`; - // Normalize relative path separators - const normalizedRelative = relativePath.replace(/\\/g, "/"); + // Extract the scheme+authority prefix (e.g. "file:///", "workspace:///") exactly, + // so that empty-segment collapsing below never erases the ":///" triple slash. + const schemeMatch = full.match(/^([a-zA-Z][a-zA-Z0-9+\-.]*:\/\/\/?)/); + if (!schemeMatch) { + throw new Error(`Invalid base URI (no scheme): ${base}`); + } + const schemePrefix = schemeMatch[1]; + const pathStr = full.slice(schemePrefix.length); - // Simple resolution: append relative to base directory - // Handle ../ and ./ segments - const parts = `${baseDir}/${normalizedRelative}`.split("/"); + // Resolve . and .. segments + const parts = pathStr.split("/"); const resolved: string[] = []; - for (const part of parts) { if (part === "..") { resolved.pop(); @@ -112,15 +115,7 @@ export function resolveUri(base: StringUri, relativePath: string): StringUri { } } - // Reconstruct with proper scheme prefix - const result = resolved.join("/"); - - // Ensure file:// URIs have triple slash for absolute paths - if (result.startsWith("file:/") && !result.startsWith("file:///")) { - return result.replace("file:/", "file:///") as StringUri; - } - - return result as StringUri; + return (schemePrefix + resolved.join("/")) as StringUri; } /** @@ -229,7 +224,7 @@ export interface HostInterface { /** Central configuration provider (framework-agnostic). */ config: FullConfigInterface; - existsInSameWorkspace(knownUri: string, desiredUri: string): Promise; + existsInSameWorkspace(knownUri: StringUri, desiredPath: string): Promise; exists(uri: StringUri, unsafe?: boolean): Promise; resolveFile( diff --git a/src/utils.ts b/src/utils.ts index 8f4f67d..fe8d643 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -275,9 +275,12 @@ export class VSCodeHost implements HostInterface { */ private absPathToStringUri(absPath: string): StringUri { const norm = path.normalize(absPath); + const isWin = process.platform === "win32"; + const normCmp = isWin ? norm.toLowerCase() : norm; for (const folder of vscode.workspace.workspaceFolders || []) { const folderPath = path.normalize(folder.uri.fsPath); - if (norm.toLowerCase().startsWith(folderPath.toLowerCase() + path.sep)) { + const folderCmp = isWin ? folderPath.toLowerCase() : folderPath; + if (normCmp.startsWith(folderCmp + path.sep)) { const relativePath = path.relative(folderPath, norm).split(path.sep).join('/'); return `workspace:///${folder.name}/${relativePath}` as StringUri; } From 1f0c89d0b676e515fcbbd2aa1c220403f025082f Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Wed, 1 Jul 2026 17:24:12 -0700 Subject: [PATCH 8/9] Third time I've tried to fix this file. --- src/test/suite/include-disk-integration.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/suite/include-disk-integration.test.ts b/src/test/suite/include-disk-integration.test.ts index 577f5a1..e2cecee 100644 --- a/src/test/suite/include-disk-integration.test.ts +++ b/src/test/suite/include-disk-integration.test.ts @@ -24,12 +24,12 @@ function normalizePathsForComparison(content: string, workspaceRoot: string): st const workspaceUri = filePathToStringUri(workspaceRoot); // Extract the path portion after file:/// const workspaceUriPath = workspaceUri.replace(/^file:\/\/\//, ''); - + // Replace absolute paths with relative-style paths matching expected output format // The expected files use format like: file:///test/workspace/set_1/... // We need to replace: file:///c:/Users/.../src/test/workspace/set_1/... // with: file:///test/workspace/set_1/... - + const absolutePattern = new RegExp( `file:///${workspaceUriPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\//g, '/')}`, 'gi' @@ -238,7 +238,7 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { // Normalize paths for comparison (actual output has absolute URIs, expected has relative) const normalizedContent = normalizePathsForComparison(result.content, workspaceRoot); - + // Compare with expected output assert.strictEqual(normalizedContent, expected, 'Output should match expected file'); @@ -259,7 +259,7 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { // Normalize paths for comparison const normalizedContent = normalizePathsForComparison(result.content, workspaceRoot); - + // Compare with expected output assert.strictEqual(normalizedContent, expected, 'Output should match expected file'); @@ -284,7 +284,7 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { // Normalize paths for comparison const normalizedContent = normalizePathsForComparison(result.content, workspaceRoot); - + // Compare with expected output assert.strictEqual(normalizedContent, expected, 'Output should match expected file'); @@ -384,7 +384,7 @@ suite('LSL Include Directive Tests - Disk-based Integration', () => { // Normalize paths for comparison const normalizedContent = normalizePathsForComparison(result.content, workspaceRoot); - + // Compare with expected output assert.strictEqual(normalizedContent, expected, 'Output should match expected file'); From e2fb5cfacb1541267fa2514b297a8e676d34cf9c Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Thu, 2 Jul 2026 17:07:54 -0700 Subject: [PATCH 9/9] link scripts from published objects with scripts in the local directory. --- src/extension.ts | 1 + src/scriptsync.ts | 125 +++++++++++++++++++-------- src/synchservice.ts | 129 +++++++++++++++++++++++++--- src/vscode/objectcontentprovider.ts | 3 +- src/vscode/objectcontentservice.ts | 10 +++ 5 files changed, 219 insertions(+), 49 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 0180cf2..eafc240 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -75,6 +75,7 @@ export function activate(context: vscode.ExtensionContext): void { if (slIdx !== -1) { vscode.workspace.updateWorkspaceFolders(slIdx, 1); } + synchService.evictSlSyncs(object_id); } else if (type === "updated") { if (slIdx !== -1) { const entry = objectContentService.getObject(object_id); diff --git a/src/scriptsync.ts b/src/scriptsync.ts index 31c45d6..364edfb 100644 --- a/src/scriptsync.ts +++ b/src/scriptsync.ts @@ -33,18 +33,28 @@ import { sha256 } from "js-sha256"; import { getLanguageConfig, isProccessedLanguage, LanguageLexerConfig } from "./shared/lexer"; //==================================================================== -interface TrackedDocument { - id: string; - viewerDocument: vscode.TextDocument; - watcher?: vscode.FileSystemWatcher; - hash?: string; +interface TrackedLocalFile { + kind: 'local'; + id: string; + viewerDocument: vscode.TextDocument; + watcher?: vscode.FileSystemWatcher; + hash?: string; } +interface TrackedVirtualFile { + kind: 'virtual'; + id: string; // uri.toString() — stable unique key + uri: vscode.Uri; + hash?: string; +} + +type TrackedFile = TrackedLocalFile | TrackedVirtualFile; + export class ScriptSync implements vscode.Disposable { private saveListener: vscode.Disposable | undefined; private masterDocument: vscode.TextDocument; private language: ScriptLanguage; - private fileMappings: TrackedDocument[] = []; + private fileMappings: TrackedFile[] = []; private macros: MacroProcessor; private preprocessor: LexingPreprocessor | undefined; private disposed: boolean = false; @@ -63,7 +73,7 @@ export class ScriptSync implements vscode.Disposable { language: ScriptLanguage, config: ConfigService, scriptId: string, - viewerDocument: vscode.TextDocument, + viewerDocument: vscode.TextDocument | undefined, syncService: SynchService, ) { this.config = config; @@ -121,7 +131,7 @@ export class ScriptSync implements vscode.Disposable { return false; } - let mapping: TrackedDocument = { id, viewerDocument }; + let mapping: TrackedLocalFile = { kind: 'local', id, viewerDocument }; mapping.watcher = createFileWatcher(viewerDocument); mapping.watcher.onDidDelete((e) => { @@ -131,8 +141,8 @@ export class ScriptSync implements vscode.Disposable { this.fileMappings.push(mapping); console.log("Subscribeing."); - // on initial subscription, we need to generate an inital line mapping - if (this.fileMappings.length === 1) { + // on initial subscription, we need to generate an initial line mapping + if (this.fileMappings.filter(m => m.kind === 'local').length === 1) { this.lineMappings = LineMapper.parseLineMappingsFromContent( viewerDocument.getText(), this.language, @@ -142,13 +152,24 @@ export class ScriptSync implements vscode.Disposable { return true; } + public subscribeVirtual(uri: vscode.Uri): boolean { + const id = uri.toString(); + if (this.isTrackingId(id)) { + return false; // already tracking + } + this.fileMappings.push({ kind: 'virtual', id, uri }); + return true; + } + public unsubscribeById(id: string, close?: boolean): number { const mapping = this.fileMappings.find((m) => m.id === id); if (mapping) { this.fileMappings = this.fileMappings.filter((m) => m !== mapping); if (close) { - closeTextDocument(mapping.viewerDocument); - mapping.watcher?.dispose(); + if (mapping.kind === 'local') { + closeTextDocument(mapping.viewerDocument); + mapping.watcher?.dispose(); + } } if(!this.hasFilesToTrack()) { this.syncService.clearEmptySyncs(); @@ -160,7 +181,9 @@ export class ScriptSync implements vscode.Disposable { public unsubscribeByFile(viewerFile: string, close?: boolean): number { viewerFile = path.normalize(viewerFile); const mapping = this.fileMappings.find( - (m) => path.normalize(m.viewerDocument.fileName) === viewerFile, + (m): m is TrackedLocalFile => + m.kind === 'local' && + path.normalize(m.viewerDocument.fileName) === viewerFile, ); if (mapping) { this.unsubscribeById(mapping.id, close); @@ -168,6 +191,18 @@ export class ScriptSync implements vscode.Disposable { return this.fileMappings.length; } + public unsubscribeVirtualByUri(uri: vscode.Uri, close?: boolean): void { + this.unsubscribeById(uri.toString(), close); + } + + public evictVirtualMappingsForObject(object_id: string): void { + const prefix = `/${object_id}/`; + this.fileMappings = this.fileMappings.filter( + (m) => m.kind !== 'virtual' || + (!m.uri.path.startsWith(prefix) && m.uri.path !== `/${object_id}`) + ); + } + //#endregion //==================================================================== //#region Properties @@ -176,11 +211,18 @@ export class ScriptSync implements vscode.Disposable { } public isTrackingFile(viewerFile: string): boolean { + // TODO: revisit use of fileName for comparison — for remote/virtual workspace + // support this should use viewerDocument.uri.toString() instead. return this.fileMappings.some( - (mapping) => mapping.viewerDocument.fileName === viewerFile, + (m): m is TrackedLocalFile => + m.kind === 'local' && m.viewerDocument.fileName === viewerFile, ); } + public isTrackingVirtualUri(uri: vscode.Uri): boolean { + return this.isTrackingId(uri.toString()); + } + public hasFilesToTrack() : boolean { return this.fileMappings.length > 0; } @@ -202,7 +244,11 @@ export class ScriptSync implements vscode.Disposable { } public getTrackedIds(): string[] { - return this.fileMappings.map((mapping) => mapping.id); + // Only return local script IDs — virtual URI strings must not be sent + // to the viewer as script.subscribe targets. + return this.fileMappings + .filter((m): m is TrackedLocalFile => m.kind === 'local') + .map((m) => m.id); } //#endregion @@ -468,33 +514,40 @@ export class ScriptSync implements vscode.Disposable { masterFilePath, "utf8", ); - let finalContent = await this.preProcessContent(originalContent); + const processedContent = await this.preProcessContent(originalContent); const sha = sha256.create(); - sha.update(finalContent); + sha.update(processedContent); const hash = sha.hex(); - // console.error(finalContent, hash); - finalContent = this.prefixWithMetaInformation(finalContent, hash); + const prefixedContent = this.prefixWithMetaInformation(processedContent, hash); - // Walk through all TrackedDocuments and save their finalContents if the hash has changed + // Walk through all tracked files and save if hash has changed await Promise.all( this.getFileMappingsFilteredByHash(hash) .map((mapping) => { - if(masterFilePath == mapping.viewerDocument.fileName) { - // Do not write to the same file we are processing from - // Allows quick editing of a script externally that hasnt matched - // Without the script extending forever if the preproc is enabled - return Promise.resolve(); - } mapping.hash = hash; - return fs.promises.writeFile( - mapping.viewerDocument.fileName, - finalContent, - "utf8", - ); - } - ), + if (mapping.kind === 'local') { + if (masterFilePath === mapping.viewerDocument.fileName) { + // Do not write to the same file we are processing from. + // Allows quick editing of a script externally that hasn't matched + // without the script extending forever if the preproc is enabled. + return Promise.resolve(); + } + return fs.promises.writeFile( + mapping.viewerDocument.fileName, + prefixedContent, + "utf8", + ); + } else { + // Virtual (sl://) — same content as local temp files, + // written via the virtual filesystem provider + return vscode.workspace.fs.writeFile( + mapping.uri, + Buffer.from(prefixedContent, "utf-8"), + ); + } + }), ); } catch (err: any) { @@ -535,7 +588,7 @@ export class ScriptSync implements vscode.Disposable { return meta.join("\n"); } - private getFileMappingsFilteredByHash(hash:string) : TrackedDocument[] { + private getFileMappingsFilteredByHash(hash:string) : TrackedFile[] { if(!ConfigService.getInstance().getConfig(ConfigKey.CompareHashBeforeSync, false)) { return this.fileMappings; } @@ -596,7 +649,9 @@ export class ScriptSync implements vscode.Disposable { try { this.diagnosticCollection.dispose(); - this.fileMappings.forEach(map => map.watcher?.dispose()); + this.fileMappings.forEach(map => { + if (map.kind === 'local') map.watcher?.dispose(); + }); } catch (error) { // Log but don't throw during disposal console.warn("Error during ScriptSync disposal:", error); diff --git a/src/synchservice.ts b/src/synchservice.ts index 30b4669..6a93527 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -24,6 +24,7 @@ import { ObjectPublishMessage, ObjectUnpublishMessage, ObjectUpdateMessage, + ObjectInventoryItem, } from "./vscode/objectcontentinterfaces"; import { hasWorkspace, @@ -42,7 +43,7 @@ import { getLanguageConfig } from "./shared/lexer"; import { HostInterface } from "./interfaces/hostinterface"; import { SyncedFileDecorator } from "./vscode/SyncedFileDecorator"; import { ObjectContentService } from "./vscode/objectcontentservice"; -import { SL_SCHEME, SL_AUTHORITY } from "./vscode/objectcontentprovider"; +import { SL_SCHEME, SL_AUTHORITY, displayName } from "./vscode/objectcontentprovider"; type ParsedTempFile = { scriptName: string; scriptId: string; extension: string, language: ScriptLanguage }; @@ -202,6 +203,10 @@ export class SynchService implements vscode.Disposable { private async setupSync( viewerDocument: vscode.TextDocument, ): Promise { + if (viewerDocument.uri.scheme === SL_SCHEME) { + await this.setupSyncForSlUri(viewerDocument); + return true; + } const viewerFilePath = path.normalize(viewerDocument.uri.fsPath); const openedBase = path.basename(viewerFilePath); @@ -277,17 +282,8 @@ export class SynchService implements vscode.Disposable { // Already syncing the master, add another id and viewer file syncs.forEach(sync => sync.subscribe(parsed.scriptId, viewerDocument)); } else { - const config = ConfigService.getInstance(); - const sync = new ScriptSync( - masterDoc, - parsed.extension as ScriptLanguage, - config, - parsed.scriptId, - viewerDocument, - this, - ); - await sync.initialize(); - this.activeSyncs.set(masterUri.toString(), sync); + const sync = await this.getOrCreateSync(masterDoc, parsed.extension as ScriptLanguage); + sync.subscribe(parsed.scriptId, viewerDocument); syncs.push(sync); } @@ -308,6 +304,56 @@ export class SynchService implements vscode.Disposable { return true; } + private async getOrCreateSync( + masterDoc: vscode.TextDocument, + language: ScriptLanguage, + ): Promise { + const key = masterDoc.uri.toString(); + const existing = this.activeSyncs.get(key); + if (existing) return existing; + + const config = ConfigService.getInstance(); + const sync = new ScriptSync(masterDoc, language, config, '', undefined, this); + await sync.initialize(); + this.activeSyncs.set(key, sync); + return sync; + } + + private async setupSyncForSlUri( + slDocument: vscode.TextDocument, + ): Promise { + if (!hasWorkspace()) { + return; + } + if (!this.websocket?.isConnected()) { + showWarningMessage(`Cannot link sl:// script: not connected to Second Life viewer.`); + return; + } + const parsed = SynchService.parseSlFileInfo(slDocument.uri); + if (!parsed) { + logInfo(`[setupSyncForSlUri] Could not parse sl:// URI: ${slDocument.uri.toString()}`); + return; + } + const masterUri = await SynchService.findMasterFile(parsed, slDocument); + if (!masterUri) { + logInfo( + `[setupSyncForSlUri] No master found for "${parsed.scriptName}.${parsed.extension}"; ` + + `editing directly via viewer.`, + ); + return; + } + const masterEditor = await SynchService.openMasterScript(masterUri); + const sync = await this.getOrCreateSync(masterEditor.document, parsed.language); + sync.subscribeVirtual(slDocument.uri); + this.syncedFileDecorator.refresh(masterEditor.document.uri); + logInfo( + `[setupSyncForSlUri] Linked "${parsed.scriptName}" ` + + `(${slDocument.uri.toString()}) \u2192 ${masterUri.fsPath}`, + ); + // Do NOT call setupConnection() — already connected + // Do NOT call sendSyncSubscription() — sl:// content travels via object.content.save + } + public removeSync(filePath: string): void { // seeing if we closed a temp file or a master file let sync = this.findSyncByMasterFilePath(filePath); @@ -323,6 +369,13 @@ export class SynchService implements vscode.Disposable { this.clearEmptySyncs(); } + public evictSlSyncs(object_id: string): void { + for (const [, sync] of this.activeSyncs) { + sync.evictVirtualMappingsForObject(object_id); + } + this.clearEmptySyncs(); + } + public clearEmptySyncs() : void { for(const [key,sync] of this.activeSyncs) { if(!sync.hasFilesToTrack()) { @@ -729,6 +782,51 @@ export class SynchService implements vscode.Disposable { : null; } + private static parseSlFileInfo(uri: vscode.Uri): ParsedTempFile | null { + if (uri.scheme !== SL_SCHEME || uri.authority !== SL_AUTHORITY) { + return null; + } + // Path segments after stripping the leading "/" + // Structure: //[/] + const segments = uri.path.replace(/^\//, '').split('/'); + if (segments.length < 2) { + return null; + } + + const root_id = segments[0]; + const lastSeg = segments[segments.length - 1]; + + // Always resolve name and type from the inventory record, never from the URI path. + // The last segment may be a UUID (itemUri) or a display name (Explorer) — both are + // matched against the service inventory. + const item = SynchService.findSlInventoryItem(root_id, lastSeg); + if (!item) return null; + + const fullName = displayName(item); // e.g. "My Script.luau" + const di = fullName.lastIndexOf('.'); + if (di < 0) return null; + + const scriptName = fullName.slice(0, di); // "My Script" + const extension = fullName.slice(di + 1).toLowerCase(); // "luau" + const language: ScriptLanguage = extension === 'lsl' ? 'lsl' : 'luau'; + + return { scriptName, scriptId: uri.toString(), extension, language }; + } + + private static findSlInventoryItem( + root_id: string, + seg: string, + ): ObjectInventoryItem | undefined { + const service = ObjectContentService.getInstance(); + for (const inv of service.getAllInventories(root_id)) { + const byId = inv.find(i => i.item_id === seg); + if (byId) return byId; + const byName = inv.find(i => displayName(i) === seg); + if (byName) return byName; + } + return undefined; + } + public findSyncByScriptId(scriptId: string): ScriptSync | undefined { return [...this.activeSyncs.values()].find((sync) => sync.isTrackingId(scriptId), @@ -769,12 +867,17 @@ export class SynchService implements vscode.Disposable { if(metaMatch) return metaMatch; let files = await vscode.workspace.findFiles(`**/${script.scriptName}.${script.extension}`); + // Only consider local files as master candidates. Remote/virtual workspace support + // would require updating ScriptSync.initialize and handleMasterSaved to use + // vscode.workspace.fs.readFile instead of fs.promises.readFile. + files = files.filter(f => f.scheme === 'file'); if (files.length > 0) { return files[0]; } else { // Not found a glob match, try a broader fit // Get all files with right extenstion - const possibleFiles = await vscode.workspace.findFiles(`**/*.${script.extension}`) + const possibleFiles = (await vscode.workspace.findFiles(`**/*.${script.extension}`)) + .filter(f => f.scheme === 'file'); // see note above for (const possibleFile of possibleFiles) { const wsFile = vscode.Uri.file(vscode.workspace.asRelativePath(possibleFile)); // filter out paths with hidden directories diff --git a/src/vscode/objectcontentprovider.ts b/src/vscode/objectcontentprovider.ts index 222757f..3cfe30c 100644 --- a/src/vscode/objectcontentprovider.ts +++ b/src/vscode/objectcontentprovider.ts @@ -262,7 +262,7 @@ function extensionForItem(item: ObjectInventoryItem): string { } /** Returns the display filename (name + synthetic extension) */ -function displayName(item: ObjectInventoryItem): string { +export function displayName(item: ObjectInventoryItem): string { return item.name + extensionForItem(item); } @@ -624,6 +624,7 @@ export class ObjectContentProvider implements vscode.FileSystemProvider, vscode. this.service.cacheContent(root_id, parsed.item_id, content); this.service.markContentSaved(root_id, parsed.item_id); + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); return; } catch (error) { if (error instanceof vscode.FileSystemError) { diff --git a/src/vscode/objectcontentservice.ts b/src/vscode/objectcontentservice.ts index 23410af..4c6ccf6 100644 --- a/src/vscode/objectcontentservice.ts +++ b/src/vscode/objectcontentservice.ts @@ -224,6 +224,16 @@ export class ObjectContentService implements vscode.Disposable { ); } + /** Returns inventory arrays for all prims in the object (root first, then linked). */ + getAllInventories(object_id: string): ObjectInventoryItem[][] { + const entry = this.objects.get(object_id); + if (!entry) return []; + return [ + entry.object.inventory ?? [], + ...(entry.object.linked_objects ?? []).map(lo => lo.inventory ?? []), + ]; + } + // ============================================ // Content Cache // ============================================