All files / providers workspace-uri.ts

92.1% Statements 70/76
93.44% Branches 57/61
80.95% Functions 17/21
92.1% Lines 70/76

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348                                            67x           67x         67x                                                       125x 31x     94x 1x     93x 59x         34x                           382x 84x         298x 4x             294x 9x     285x                     310x 5x       5x 1x     4x             305x 6x     299x 299x   299x 232x                   67x 66x 2x       65x                       234x                                                   395x           395x   395x 256x     139x                   139x                 34x 1x     33x   33x 2x     31x 34x           34x 6x       25x                       13x 13x   3x                       31x       31x         31x 5x         26x       3x 1x     2x 1x     1x   1x                         239x   76x   69x   64x             121x     1x             69x            
import * as vscode from 'vscode';
import { Utils } from 'vscode-uri';
import { UserUri } from './user-uri';
 
/**
 * A helper class which provides methods to convert between absolute file system 
 * URIs and workspace-relative URIs in the Mentor virtual file system. This is used
 * to provide shortened document URIs that are also resolvable when stored in a
 * version control system repository.
 * 
 * When a monorepo root is configured (via the `mentor.workspace.rootOffset` setting in
 * a `.code-workspace` file), all workspace-relative paths are resolved against that root.
 * This ensures that graph IRIs are identical across different workspaces that share the 
 * same monorepo root.
 * 
 * @note We need vscode.workspaces here to resolve the URIs. So this helper cannot be used
 * in webview or LSP processes.
 */
export class WorkspaceUri {
	/**
	 * The URI scheme for the workspace relative Mentor virtual file system URIs.
	 */
	static readonly uriScheme = 'workspace';
 
	/**
	 * A regular expression to match Mentor VFS URIs in text documents.
	 * @note This is intentionally a string so that any modifiers for the evaluation can be easily applied as needed.
	 */
	static readonly uriRegex = `${this.uriScheme}://[^\\s>]+`;
 
	/**
	 * A set of URI schemes that can be translated to workspace-relative URIs.
	 */
	static readonly supportedSchemes = new Set<string>([
		"file",
		"vscode-notebook-cell",
		"vscode-vfs"
	]);
 
	/**
	 * The monorepo root URI to resolve workspace-relative paths against.
	 * When set, all workspace URIs are relative to this root instead of the first workspace folder.
	 * Set by `WorkspaceService` after discovery.
	 */
	static rootUri: vscode.Uri | undefined;
 
	/**
	 * Returns the canonical string representation of a workspace URI.
	 * 
	 * This is needed because `vscode.Uri.toString()` drops the empty authority,
	 * serializing `workspace:///path` as `workspace:/path`. This method ensures the
	 * canonical triple-slash form `workspace:///path` is always produced.
	 * 
	 * For `user:` URIs, delegates to {@link UserUri.toCanonicalString}, which applies
	 * the same authority fix — this matters because graph IRIs derived from open
	 * documents flow through this method, and a `user:///` document must produce
	 * the same graph IRI here as the settings-backed shape loader uses.
	 * For other non-workspace URIs, delegates to `toString(true)` (skip encoding).
	 * For string inputs, returns the string unchanged.
	 */
	static toCanonicalString(uri: vscode.Uri | string): string {
		if (typeof uri === 'string') {
			return uri;
		}
 
		if (uri.scheme === UserUri.uriScheme) {
			return UserUri.toCanonicalString(uri);
		}
 
		if (uri.scheme !== this.uriScheme) {
			return uri.toString(true);
		}
 
		// uri.toString() produces properly percent-encoded path segments but drops the empty authority,
		// serialising `workspace:///path` as `workspace:/path`. Fix the authority component.
		return uri.toString().replace(/^workspace:\/(?!\/)/, 'workspace:///');
	}
 
	/**
	 * Returns the effective root URI for workspace-relative path resolution.
	 *
	 * Priority:
	 * 1. Explicit monorepo root (`rootUri` set via `mentor.workspace.rootOffset`).
	 * 2. Parent directory of the active `.code-workspace` file — this ensures that
	 *    folder names are preserved in workspace URIs (e.g. `workspace:///examples/file.ttl`
	 *    instead of `workspace:///file.ttl`).
	 * 3. First workspace folder (single-folder workspace, no workspace file).
	 */
	static getEffectiveRootUri(): vscode.Uri | undefined {
		if (this.rootUri) {
			return this.rootUri;
		}
 
		const folders = vscode.workspace.workspaceFolders;
 
		if (!folders || folders.length === 0) {
			return undefined;
		}
 
		// When a .code-workspace file is open, its parent directory is the natural root —
		// all folder paths in the workspace file are relative to it.
		const workspaceFile = vscode.workspace.workspaceFile;
 
		if (workspaceFile) {
			return Utils.dirname(workspaceFile);
		}
 
		return folders[0].uri;
	}
 
	/**
	 * Converts an absolute file system URI (file://..) to a workspace-relative Mentor VFS URI that
	 * can be resolved by the Mentor document link provider and the Mentor virtual file system provider.
	 * @param documentIri The absolute file system URI to convert.
	 * @param fragmentOverride When provided, overrides the URI fragment (e.g. a notebook cell slug).
	 * @returns The corresponding Mentor VFS URI.
	 */
	static toWorkspaceUri(documentIri: vscode.Uri, fragmentOverride?: string): CanonicalWorkspaceUri | undefined {
		if (documentIri.scheme === this.uriScheme) {
			const result = documentIri instanceof CanonicalWorkspaceUri
				? documentIri
				: new CanonicalWorkspaceUri(documentIri);
 
			if (fragmentOverride !== undefined) {
				return new CanonicalWorkspaceUri(result.with({ fragment: fragmentOverride }));
			}
 
			return result;
		}
 
		// Only translate schemes that actually address workspace files. Virtual schemes
		// (user:, untitled:, mentor-template:, …) must keep their own identity — matching
		// them by path prefix would mis-map e.g. `user:///shapes/x.ttl` to a workspace URI
		// whenever the effective root path happens to be a prefix of the virtual path.
		if (!this.supportedSchemes.has(documentIri.scheme)) {
			return undefined;
		}
 
		const absolutePath = documentIri.path;
		const root = this.getEffectiveRootUri();
 
		if (root && this.isPathPrefix(root.path, absolutePath)) {
			return this.toRelativeWorkspaceUri(documentIri, root.path.length, fragmentOverride);
		}
 
		// Fallback: try the workspace folders when the effective root did not match.
		//
		// This is not limited to the monorepo-root case. With a `.code-workspace` file open,
		// `getEffectiveRootUri()` derives the root from `vscode.workspace.workspaceFile`, while
		// the files being mapped come from `findFiles()` over `workspaceFolders` -- two different
		// VS Code APIs whose URIs need not agree. Gating this fallback on `rootUri` skipped it in
		// exactly the configuration where it was needed, leaving every workspace file unmappable.
		for (const folder of vscode.workspace.workspaceFolders ?? []) {
			if (this.isPathPrefix(folder.uri.path, absolutePath)) {
				return this.toRelativeWorkspaceUri(documentIri, folder.uri.path.length, fragmentOverride);
			}
		}
 
		return undefined;
	}
 
	/**
	 * Builds the workspace-relative URI for a document whose path is known to start with a
	 * root path of the given length.
	 * @param documentIri The absolute document URI being mapped.
	 * @param rootPathLength The length of the matched root path.
	 * @param fragmentOverride When provided, overrides the URI fragment.
	 * @returns The workspace-relative URI.
	 */
	private static toRelativeWorkspaceUri(documentIri: vscode.Uri, rootPathLength: number, fragmentOverride?: string): CanonicalWorkspaceUri {
		return new CanonicalWorkspaceUri(vscode.Uri.from({
			scheme: this.uriScheme,
			// Sliced from the original path, so the relative path keeps the casing the file
			// system actually reports even when the prefix matched case-insensitively.
			path: documentIri.path.substring(rootPathLength),
			fragment: fragmentOverride ?? (documentIri.fragment || undefined)
		}));
	}
 
	/**
	 * Determines whether `path` starts with `prefix`, tolerating the case differences that occur
	 * on case-insensitive (Windows) file systems.
	 *
	 * The drive letter in particular is normalised inconsistently across the VS Code API surface:
	 * `vscode.workspace.workspaceFile` can carry `/C:/...` while `workspaceFolders` -- and every
	 * `findFiles()` result derived from them -- carries `/c:/...`. Because `Uri.toString()`
	 * lowercases the drive letter while `Uri.path` preserves it, such a mismatch is invisible in
	 * logs even though an exact prefix test fails on every single file.
	 *
	 * Case-insensitive matching is applied only to paths rooted at a Windows drive letter. POSIX
	 * paths stay case-sensitive, where `/w/File.ttl` and `/w/file.ttl` are distinct files.
	 * @param prefix The candidate prefix (a root path).
	 * @param path The path to test.
	 * @returns `true` if `path` starts with `prefix`.
	 */
	private static isPathPrefix(prefix: string, path: string): boolean {
		Iif (path.length < prefix.length) {
			return false;
		}
 
		// Comparing an equal-length slice rather than using `startsWith` keeps both operands the
		// same length, so case folding cannot shift the boundary between prefix and remainder.
		const head = path.substring(0, prefix.length);
 
		if (head === prefix) {
			return true;
		}
 
		return this.isWindowsDrivePath(prefix) && head.toLowerCase() === prefix.toLowerCase();
	}
 
	/**
	 * Determines whether a URI path is rooted at a Windows drive letter (e.g. `/c:/Users`), which
	 * implies a case-insensitive file system.
	 * @param path The URI path to test.
	 * @returns `true` for drive-letter paths.
	 */
	private static isWindowsDrivePath(path: string): boolean {
		return /^\/[a-zA-Z]:(\/|$)/.test(path);
	}
 
	/**
	 * Resolves a workspace-relative URI into an absolute file system URI (file://..).
	 * @param workspaceUri The workspace-relative URI.
	 * @returns The absolute file URI.
	 */
	static toFileUri(workspaceUri: vscode.Uri): vscode.Uri {
		if (workspaceUri.scheme !== this.uriScheme) {
			throw new Error('Cannot convert non-workspace URI to file URI: ' + workspaceUri.toString());
		}
 
		const root = this.getEffectiveRootUri();
 
		if (!root) {
			throw new Error('No workspace folders are open.');
		}
 
		const path = workspaceUri.path.startsWith('/') ? workspaceUri.path.substring(1) : workspaceUri.path;
		const fileUri = vscode.Uri.joinPath(root, path);
 
		// `joinPath` normalizes `../` segments, so a workspace URI such as
		// `workspace:///../../etc/passwd` would otherwise resolve to a path outside the
		// workspace root. Verifying the *resolved* path stays under the root rejects path
		// traversal regardless of how it was encoded (`../`, `%2e%2e`, absolute paths).
		if (!this.isContainedIn(fileUri, root)) {
			throw new Error('Refusing to resolve workspace URI outside the workspace root: ' + workspaceUri.toString());
		}
 
		// Preserve the fragment (e.g., notebook cell index)
		return fileUri.with({ fragment: workspaceUri.fragment });
	}
 
	/**
	 * Resolves a workspace-relative URI into an absolute file system URI, returning `undefined`
	 * instead of throwing when the URI cannot be resolved or would escape the workspace root.
	 * Use this where an invalid or malicious URI should be silently skipped rather than surfaced
	 * as an error (e.g. when deciding whether to offer a document link).
	 * @param workspaceUri The workspace-relative URI.
	 * @returns The absolute file URI, or `undefined` if it cannot be safely resolved.
	 */
	static tryToFileUri(workspaceUri: vscode.Uri): vscode.Uri | undefined {
		try {
			return this.toFileUri(workspaceUri);
		} catch {
			return undefined;
		}
	}
 
	/**
	 * Determines whether a resolved URI is contained within a root URI (the root itself, or a
	 * descendant path). Used to prevent path traversal out of the workspace root.
	 * @param child The resolved URI to check.
	 * @param root The root URI that must contain the child.
	 * @returns `true` if `child` is `root` or a path beneath it.
	 */
	private static isContainedIn(child: vscode.Uri, root: vscode.Uri): boolean {
		Iif (child.scheme !== root.scheme || child.authority !== root.authority) {
			return false;
		}
 
		const rootPath = root.path.endsWith('/') ? root.path.slice(0, -1) : root.path;
 
		// The same case rules as the workspace-relative mapping apply here: a root and a child
		// that differ only in drive-letter case denote the same directory, and rejecting them
		// would make the traversal guard fire on legitimate URIs.
		if (!this.isPathPrefix(rootPath, child.path)) {
			return false;
		}
 
		// The child is either the root itself or a path below it -- a mere string prefix would
		// also accept a sibling such as `/workspace` for the root `/w`.
		return child.path.length === rootPath.length || child.path[rootPath.length] === '/';
	}
 
	static toNotebookCellUri(workspaceUri: vscode.Uri): vscode.Uri {
		if (workspaceUri.scheme !== this.uriScheme) {
			throw new Error('Cannot convert non-workspace URI to notebook cell URI: ' + workspaceUri.toString());
		}
 
		if (!workspaceUri.fragment) {
			throw new Error('Workspace URI does not have a fragment for the notebook cell: ' + workspaceUri.toString());
		}
 
		const fileUri = this.toFileUri(workspaceUri);
 
		return vscode.Uri.parse(`vscode-notebook-cell://${fileUri.authority}${fileUri.path}#${workspaceUri.fragment}`);
	}
}
 
/**
 * A workspace-scheme URI whose `toString()` always returns the canonical `workspace:///path`
 * form. Obtain instances through {@link WorkspaceUri.toWorkspaceUri}.
 *
 * `vscode.Uri.toString()` drops the empty authority component for `workspace:` URIs and returns
 * the deprecated `workspace:/path` form. This class encodes the correct serialisation directly,
 * so callers can safely use `.toString()` without any extra conversion step.
 */
export class CanonicalWorkspaceUri {
	constructor(private readonly _inner: vscode.Uri) {}
 
	get scheme(): string { return this._inner.scheme; }
	get authority(): string { return this._inner.authority; }
	get path(): string { return this._inner.path; }
	get query(): string { return this._inner.query; }
	get fragment(): string { return this._inner.fragment; }
	get fsPath(): string { return this._inner.fsPath; }
 
	/**
	 * The workspace-relative path without a leading slash (e.g. `models/data.ttl`),
	 * the form used by settings keys such as validation profile include/exclude entries.
	 */
	get relativePath(): string { return this._inner.path.replace(/^\/+/, ''); }
 
	with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): vscode.Uri {
		return this._inner.with(change);
	}
 
	/** Always returns the canonical `workspace:///path` form with percent-encoded path segments. */
	toString(_skipEncoding?: boolean): string {
		// this._inner.toString() produces properly percent-encoded path segments but drops the empty
		// authority, serialising `workspace:///path` as `workspace:/path`. Fix the authority component.
		return this._inner.toString().replace(/^workspace:\/(?!\/)/, 'workspace:///');
	}
 
	toJSON(): object {
		return this._inner.toJSON();
	}
}