From 6f03dca63c355e2709cf7127a10a692ef2477d31 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Wed, 1 Jul 2026 16:53:22 -0700 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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');