Skip to content
Open
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
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"${workspaceFolder}/packages/vscode/sample"
],
"outFiles": ["${workspaceFolder}/packages/vscode/dist/**/*.js"],
"preLaunchTask": "npm: build:local",
"preLaunchTask": "npm: watch:local",
"autoAttachChildProcesses": true
}
]
Expand Down
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"**/.DS_Store": true
},
"editor.codeActionsOnSave": {
"source.organizeImports": "never",
"source.organizeImports.biome": "explicit"
},
"[javascript]": {
Expand Down
2 changes: 1 addition & 1 deletion .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"tasks": [
{
"type": "npm",
"script": "build:local",
"script": "watch:local",
"path": "packages/vscode",
"problemMatcher": "$tsc-watch",
"isBackground": true,
Expand Down
14 changes: 13 additions & 1 deletion packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@
"markdownDescription": "The path to a `package.json` file of a Rstest executable (it's usually inside `node_modules`) in case the extension cannot find it. It will be used to resolve Rstest API paths. This should be used as a last resort fix. Supports `${workspaceFolder}` placeholder.",
"scope": "resource",
"type": "string"
},
"rstest.configFileGlobPattern": {
"description": "Glob patterns used to discover config files. Must be an array of strings.",
"type": "array",
"items": {
"type": "string"
},
"default": [
"**/rstest.config.{mjs,ts,js,cjs,mts,cts}"
]
}
}
}
Expand All @@ -59,6 +69,7 @@
"test": "npm run test:unit && npm run test:e2e",
"typecheck": "tsc --noEmit",
"watch": "rslib build --watch",
"watch:local": "cross-env SOURCEMAP=true rslib build --watch",
"package:vsce": "npm run build && vsce package"
},
"devDependencies": {
Expand All @@ -77,6 +88,7 @@
"glob": "^7.2.3",
"mocha": "^11.7.4",
"ovsx": "^0.10.6",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"valibot": "^1.1.0"
}
}
95 changes: 49 additions & 46 deletions packages/vscode/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,69 @@
import * as v from 'valibot';
import vscode from 'vscode';

// Centralized configuration types for the extension.
// Add new keys here to extend configuration in a type-safe way.
export type ExtensionConfig = {
const configSchema = v.object({
// Glob patterns that determine which files are considered tests.
// Must be an array of strings.
testFileGlobPattern: string[];
// The path to a package.json file of a Rstest executable.
// Used as a last resort if the extension cannot auto-detect @rstest/core.
rstestPackagePath?: string;
};

export const defaultConfig: ExtensionConfig = {
// https://code.visualstudio.com/docs/editor/glob-patterns
testFileGlobPattern: [
testFileGlobPattern: v.fallback(v.array(v.string()), [
'**/*.{test,spec}.[jt]s',
'**/*.{test,spec}.[cm][jt]s',
'**/*.{test,spec}.[jt]sx',
'**/*.{test,spec}.[cm][jt]sx',
],
};
]),
// The path to a package.json file of a Rstest executable.
// Used as a last resort if the extension cannot auto-detect @rstest/core.
rstestPackagePath: v.fallback(v.optional(v.string()), undefined),
configFileGlobPattern: v.fallback(v.array(v.string()), [
'**/rstest.config.{mjs,ts,js,cjs,mts,cts}',
]),
});

export type ExtensionConfig = v.InferOutput<typeof configSchema>;

// Type-safe getter for a single config value with priority:
// workspaceFolder > workspace > user (global) > default.
// Type-safe getter for a single config value
export function getConfigValue<K extends keyof ExtensionConfig>(
key: K,
folder?: vscode.WorkspaceFolder,
scope?: vscode.ConfigurationScope | null,
): ExtensionConfig[K] {
const section = vscode.workspace.getConfiguration('rstest', folder);
const inspected = section.inspect<ExtensionConfig[K]>(key);

// Priority order (highest first): folder, workspace, user, default
const value =
inspected?.workspaceFolderValue ??
inspected?.workspaceValue ??
inspected?.globalValue ??
inspected?.defaultValue ??
defaultConfig[key];

if (key === 'testFileGlobPattern') {
const v = value as unknown;
return (isStringArray(v) ? v : defaultConfig[key]) as ExtensionConfig[K];
}

if (key === 'rstestPackagePath') {
const v = value as unknown;
return (
typeof v === 'string' && v.trim().length > 0 ? v : undefined
) as ExtensionConfig[K];
}

return value as ExtensionConfig[K];
const value = vscode.workspace.getConfiguration('rstest', scope).get(key);
return v.parse(configSchema.entries[key], value) as ExtensionConfig[K];
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of v.parse here may throw an error if the configuration value doesn't match the schema, which could crash the extension. Consider using v.safeParse instead and handling validation errors gracefully, or providing a fallback value.

Suggested change
return v.parse(configSchema.entries[key], value) as ExtensionConfig[K];
const result = v.safeParse(configSchema.entries[key], value);
if (result.success) {
return result.output as ExtensionConfig[K];
} else {
// If validation fails, use the fallback value if defined, otherwise undefined
// v.fallback sets the default value, so we can parse 'undefined' to get it
const fallbackResult = v.safeParse(configSchema.entries[key], undefined);
if (fallbackResult.success) {
return fallbackResult.output as ExtensionConfig[K];
}
return undefined as ExtensionConfig[K];
}

Copilot uses AI. Check for mistakes.
}

function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === 'string');
// Convenience to get a full, normalized config object at the given scope.
export function getConfig(
scope?: vscode.ConfigurationScope | null,
): ExtensionConfig {
return Object.fromEntries(
Object.keys(configSchema.entries).map((key) => [
key,
getConfigValue(key as keyof ExtensionConfig, scope),
]),
) as ExtensionConfig;
Comment on lines +38 to +43
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Object.fromEntries with Object.keys loses type safety. The iteration could include unexpected keys if configSchema.entries is modified. Consider using a more explicit approach or using Object.entries directly on a typed object.

Copilot uses AI. Check for mistakes.
}

// Convenience to get a full, normalized config object at the given scope.
export function getConfig(folder?: vscode.WorkspaceFolder): ExtensionConfig {
export function watchConfigValue<K extends keyof ExtensionConfig>(
key: K,
scope: vscode.ConfigurationScope | null | undefined,
listener: (
value: ExtensionConfig[K],
token: vscode.CancellationToken,
) => void,
): vscode.Disposable {
let cancelSource = new vscode.CancellationTokenSource();
listener(getConfigValue(key, scope), cancelSource.token);
const disposable = vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration(`rstest.${key}`, scope ?? undefined)) {
cancelSource.cancel();
cancelSource = new vscode.CancellationTokenSource();
listener(getConfigValue(key, scope), cancelSource.token);
Comment on lines +54 to +60
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new CancellationTokenSource on each config change without proper cleanup could lead to resource leaks. While the old source is cancelled, consider explicitly disposing it with cancelSource.dispose() before creating a new one.

Copilot uses AI. Check for mistakes.
}
});
return {
testFileGlobPattern: getConfigValue('testFileGlobPattern', folder),
rstestPackagePath: getConfigValue('rstestPackagePath', folder),
} satisfies ExtensionConfig;
dispose: () => {
disposable.dispose();
cancelSource.cancel();
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dispose handler calls both disposable.dispose() and cancelSource.cancel(), but doesn't call cancelSource.dispose(). Consider disposing the final CancellationTokenSource to prevent resource leaks.

Suggested change
cancelSource.cancel();
cancelSource.cancel();
cancelSource.dispose();

Copilot uses AI. Check for mistakes.
},
};
}
Loading
Loading