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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

## [4.20.0]

### Added

- Symlink resolution (first occurence only) for `pattern`. Meant to work for `bazel-bin` and similar use cases.
Symlink deletion is handled but recreation NOT.
[Upvote the feature here](https://github.com/matepek/vscode-catch2-test-adapter/issues/499) if interested.

## [4.19.0] - 2025-10-30

### Added
Expand Down
142 changes: 85 additions & 57 deletions src/ConfigOfExecGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export class ConfigOfExecGroup implements vscode.Disposable {

this._shared.log.info('pattern', this._pattern, this._shared.workspaceFolder.uri.fsPath, pattern);

if (pattern.isAbsolute && pattern.isPartOfWs)
if (pattern.isAbsolute && pattern.absPath.isPartOfWs)
this._shared.log.info('Absolute path is used for workspace directory. This is unnecessary, but it should work.');

if (this._pattern.indexOf('\\') != -1)
Expand Down Expand Up @@ -150,25 +150,18 @@ export class ConfigOfExecGroup implements vscode.Disposable {

let execWatcher: FSWatcher | undefined = undefined;
try {
if (pattern.isPartOfWs) {
execWatcher = new VSCFSWatcherWrapper(this._shared.workspaceFolder, pattern.relativeToWsPosix, enabledExcludes);
if (pattern.resolved.isPartOfWs) {
execWatcher = new VSCFSWatcherWrapper(
this._shared.workspaceFolder,
pattern.resolved.relativeToWsPosix,
enabledExcludes,
);
} else {
execWatcher = new GazeWrapper([pattern.absPath]);
execWatcher = new GazeWrapper([pattern.resolved.absPath]);
}

filePaths = await execWatcher.watched();

// TODO: we could figure out that it is a symlink and add extra
// filePaths.forEach(f => {
// try {
// if (fs.readlinkSync(f)) {
// console.log(`sym ${f}`);
// }
// } catch (e) {
// console.log(`not sym ${f}`);
// }
// });

execWatcher.onError((err: Error) => {
// eslint-disable-next-line
if ((err as any).code == 'ENOENT') this._shared.log.info('watcher error', err);
Expand Down Expand Up @@ -245,59 +238,93 @@ export class ConfigOfExecGroup implements vscode.Disposable {

if (errors.length > 0) return errors;

if (this._dependsOn.length > 0) {
try {
// gaze can handle more patterns at once
const absPatterns: string[] = [];

for (const pattern of this._dependsOn) {
const p = await this._pathProcessor(pattern);
if (p.isPartOfWs) {
const w = new VSCFSWatcherWrapper(this._shared.workspaceFolder, p.relativeToWsPosix, []);
this._disposables.push(w);

w.onError((e: Error): void => this._shared.log.error('dependsOn watcher:', e, p));

w.onAll((fsPath: string): void => {
this._shared.log.info('dependsOn watcher event:', fsPath);
getModiTime(fsPath).then(modiTime => {
for (const exec of this._executables.values())
exec.reloadTests(this._shared.taskPool, this._shared.cancellationToken, modiTime);
});
this.sendRetireAllExecutables();
try {
// gaze can handle more patterns at once
const absPatterns: string[] = [];

if (pattern.symlink) {
if (pattern.symlink.isPartOfWs) {
const w = new VSCFSWatcherWrapper(this._shared.workspaceFolder, pattern.symlink.relativeToWsPosix, []);
this._disposables.push(w);
w.onError((e: Error): void => this._shared.log.error('symlink watcher:', e, pattern.symlink));
w.onAll((fsPath: string): void => {
this._shared.log.info('symlink watcher event:', fsPath);
getModiTime(fsPath).then(modiTime => {
if (modiTime === undefined) {
for (const [filePath, exec] of this._executables)
if (exec) {
exec.dispose();
this._executables.delete(filePath);
}
this._shared.log.infoS('Symlink was removed', pattern);
} else {
this._shared.log.infoS(
'Symlink was created, but no discovery will happen. Usser must initiat reload via UI or command.',
pattern,
);
}
});
} else {
absPatterns.push(p.absPath);
}
this.sendRetireAllExecutables();
});
} else {
absPatterns.push(pattern.symlink.absPath);
}
}

if (absPatterns.length > 0) {
const w = new GazeWrapper(absPatterns);
for (const pattern of this._dependsOn) {
const p = await this._pathProcessor(pattern);
if (p.resolved.isPartOfWs) {
const w = new VSCFSWatcherWrapper(this._shared.workspaceFolder, p.resolved.relativeToWsPosix, []);
this._disposables.push(w);

w.onError((e: Error): void => this._shared.log.error('dependsOn watcher:', e, absPatterns));

w.onError((e: Error): void => this._shared.log.error('dependsOn watcher:', e, p));
w.onAll((fsPath: string): void => {
this._shared.log.info('dependsOn watcher event:', fsPath);
getModiTime(fsPath).then(modiTime => {
for (const exec of this._executables.values())
exec.reloadTests(this._shared.taskPool, this._shared.cancellationToken, modiTime);
});
this.sendRetireAllExecutables();
});
} else {
absPatterns.push(p.resolved.absPath);
}
} catch (e) {
this._shared.log.error('dependsOn error:', e);
}

if (absPatterns.length > 0) {
const w = new GazeWrapper(absPatterns);
this._disposables.push(w);

w.onError((e: Error): void => this._shared.log.error('dependsOn watcher:', e, absPatterns));

w.onAll((fsPath: string): void => {
this._shared.log.info('dependsOn watcher event:', fsPath);
this.sendRetireAllExecutables();
});
}
} catch (e) {
this._shared.log.error('dependsOn error:', e);
}

return [];
}

private _pathInfo(absPath: string) {
const relativeToWs = pathlib.relative(this._shared.workspaceFolder.uri.fsPath, absPath);
return {
absPath,
isPartOfWs: !relativeToWs.startsWith('..') && relativeToWs !== absPath, // pathlib.relative('B:\wp', 'C:\a\b') == 'C:\a\b'
relativeToWsPosix: relativeToWs.replace(/\\/g, '/'),
};
}

private async _pathProcessor(
path: string,
moreVarsToResolve?: readonly ResolveRuleAsync[],
): Promise<{
isAbsolute: boolean;
absPath: string;
isPartOfWs: boolean;
relativeToWsPosix: string;
absPath: { absPath: string; isPartOfWs: boolean; relativeToWsPosix: string };
resolved: { absPath: string; isPartOfWs: boolean; relativeToWsPosix: string };
symlink: { absPath: string; isPartOfWs: boolean; relativeToWsPosix: string } | null;
}> {
path = await this._resolveVariables(path, false, moreVarsToResolve);

Expand All @@ -306,13 +333,14 @@ export class ConfigOfExecGroup implements vscode.Disposable {
const absPath = isAbsolute
? vscode.Uri.file(pathlib.normalize(path)).fsPath
: vscode.Uri.file(pathlib.join(this._shared.workspaceFolder.uri.fsPath, normPattern)).fsPath;
const relativeToWs = pathlib.relative(this._shared.workspaceFolder.uri.fsPath, absPath);

const { resolvedAbsPath, symlinkAbsPath } = await c2fs.resolveFirstSymlink(absPath);

return {
isAbsolute,
absPath: absPath,
isPartOfWs: !relativeToWs.startsWith('..') && relativeToWs !== absPath, // pathlib.relative('B:\wp', 'C:\a\b') == 'C:\a\b'
relativeToWsPosix: relativeToWs.replace(/\\/g, '/'),
absPath: this._pathInfo(absPath),
resolved: this._pathInfo(resolvedAbsPath),
symlink: symlinkAbsPath ? this._pathInfo(symlinkAbsPath) : null,
};
}

Expand Down Expand Up @@ -371,10 +399,10 @@ export class ConfigOfExecGroup implements vscode.Disposable {
const resolvedEnvFile = await this._pathProcessor(this._envFile, varToValue);
try {
let envFromFile: Record<string, string> | undefined = undefined;
if (resolvedEnvFile.absPath.endsWith('.json')) {
envFromFile = readJSONSync(resolvedEnvFile.absPath);
} else if (resolvedEnvFile.absPath.indexOf('.env') !== -1) {
const content = readFileSync(resolvedEnvFile.absPath).toString();
if (resolvedEnvFile.resolved.absPath.endsWith('.json')) {
envFromFile = readJSONSync(resolvedEnvFile.resolved.absPath);
} else if (resolvedEnvFile.resolved.absPath.indexOf('.env') !== -1) {
const content = readFileSync(resolvedEnvFile.resolved.absPath).toString();
envFromFile = {};
const lines = content.split(/\r?\n/).filter(x => {
const t = x.trim();
Expand Down Expand Up @@ -419,7 +447,7 @@ export class ConfigOfExecGroup implements vscode.Disposable {
try {
const resolvedPath = await this._pathProcessor(this._executionWrapper.path, varToValue);
const resolvedArgs = await this._resolveVariables(this._executionWrapper.args, false, varToValue);
spawner = new SpawnWithExecutor(resolvedPath.absPath, resolvedArgs);
spawner = new SpawnWithExecutor(resolvedPath.resolved.absPath, resolvedArgs);
this._shared.log.info('executionWrapper was specified', resolvedPath, resolvedArgs);
} catch (e) {
this._shared.log.warn('Unable to apply executionWrapper', e);
Expand Down
42 changes: 35 additions & 7 deletions src/util/FSWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { promisify } from 'util';
import * as cp from 'child_process';
Expand Down Expand Up @@ -31,15 +32,10 @@ export function isSpawnBusyError(err: any /*eslint-disable-line*/): boolean {

///

const ExecutableFlag = fs.constants.X_OK;
const ExecutableFlag = fsp.constants.X_OK;

function accessAsync(filePath: string, flag: number): Promise<void> {
return new Promise((resolve, reject) => {
fs.access(filePath, flag, (err: Error | null) => {
if (err) reject(err);
else resolve();
});
});
return fsp.access(filePath, flag);
}

export function isNativeExecutableAsync(
Expand Down Expand Up @@ -69,3 +65,35 @@ export function getLastModiTime(filePath: string): Promise<number> {
return stat.mtimeMs;
});
}

///

export async function resolveFirstSymlink(
absPath: string,
): Promise<{ resolvedAbsPath: string; symlinkAbsPath: string | null }> {
if (!path.isAbsolute(absPath)) throw Error('absPath is expected');
const segments = absPath.split(path.sep);
let idx = 1; // either it's drive or root folder, we skip it
const potentialSymlinkSegments: string[] = [segments[0]];
while (idx < segments.length) {
potentialSymlinkSegments.push(segments[idx++]);
const potentialSymlinkPath = potentialSymlinkSegments.join(path.sep);
try {
const stat = await fsp.lstat(potentialSymlinkPath);
if (stat.isSymbolicLink()) {
const realPath = await fsp.realpath(potentialSymlinkPath);
const resolvedAbsPath = path.join(realPath, ...segments.slice(idx));
return { resolvedAbsPath, symlinkAbsPath: potentialSymlinkPath };
}
if (!stat.isDirectory()) {
return { resolvedAbsPath: absPath, symlinkAbsPath: null };
}
} catch (err) {
if (err.code === 'ENOENT') {
return { resolvedAbsPath: absPath, symlinkAbsPath: null };
}
throw err;
}
}
return { resolvedAbsPath: absPath, symlinkAbsPath: null };
}
Loading
Loading