Initial version of .NET Solution Launcher

Startup project and solution configuration (Debug|x64, Test|x64, ...) in
the status bar, with build and debug tasks that pass both configuration
and platform. Made for DotRush, whose build never passes -p:Platform and
whose status item shows the configuration but not the project.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
This commit is contained in:
max
2026-09-08 13:10:55 +02:00
co-authored by Claude Fable 5.1
commit 3f67eef9aa
21 changed files with 3279 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import * as path from 'path';
/** The parts of a project file a launcher cares about, read without evaluating MSBuild. */
export interface ProjectInfo {
fsPath: string;
name: string;
/** True for Exe / WinExe. */
executable: boolean;
assemblyName: string;
/** From `<Configurations>`; empty when the project does not declare any. */
configurations: string[];
/** From `<Platforms>`; empty when the project does not declare any (MSBuild default AnyCPU). */
platforms: string[];
/** TargetFramework or every entry of TargetFrameworks. */
targetFrameworks: string[];
/** True when the project multi-targets and needs `-p:TargetFramework` to evaluate. */
multiTargeting: boolean;
}
function property(xml: string, name: string): string | undefined {
// The first unconditional-looking occurrence wins. Conditions are not evaluated;
// for the properties read here they are rarely conditional.
const match = new RegExp(`<${name}(?:\\s[^>]*)?>\\s*([^<]*?)\\s*</${name}>`, 'i').exec(xml);
return match ? match[1] : undefined;
}
function list(value: string | undefined): string[] {
return (value ?? '')
.split(';')
.map(item => item.trim())
.filter(item => item.length > 0);
}
export function parseProject(xml: string, fsPath: string): ProjectInfo {
const name = path.basename(fsPath, path.extname(fsPath));
const outputType = property(xml, 'OutputType') ?? 'Library';
const targetFrameworks = list(property(xml, 'TargetFrameworks'));
const targetFramework = property(xml, 'TargetFramework');
return {
fsPath,
name,
executable: /^(win)?exe$/i.test(outputType.trim()),
assemblyName: property(xml, 'AssemblyName') ?? name,
configurations: list(property(xml, 'Configurations')),
platforms: list(property(xml, 'Platforms')),
targetFrameworks: targetFrameworks.length ? targetFrameworks : targetFramework ? [targetFramework] : [],
multiTargeting: targetFrameworks.length > 0,
};
}
+334
View File
@@ -0,0 +1,334 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { buildLaunchConfiguration, LAUNCH_NAME, launchJsonEntry, SolutionDebugConfigurationProvider } from './launch';
import { SolutionModel, StartupProject } from './model';
import { configurationKey, projectConfigurationFor, SolutionConfiguration } from './sln';
import { createTask, runTask, SolutionTaskProvider, TASK_TYPE, taskLabel, BuildTarget } from './tasks';
import { StatusBar } from './status';
let model: SolutionModel;
let status: StatusBar;
let log: vscode.OutputChannel;
function settings() {
return vscode.workspace.getConfiguration('dotnetSolution');
}
async function pickStartupProject(): Promise<void> {
const candidates = model.startupCandidates;
if (candidates.length === 0) {
void vscode.window.showWarningMessage(
model.activeSolution
? `${model.activeSolution.name} has no executable project (OutputType Exe).`
: 'No solution found in the workspace.');
return;
}
const picked = await vscode.window.showQuickPick(
candidates.map(project => ({
label: project.info.name,
description: vscode.workspace.asRelativePath(project.info.fsPath, false),
detail: project.info.platforms.length ? `Platforms: ${project.info.platforms.join(', ')}` : undefined,
picked: project === model.startupProject,
project,
})),
{ title: `${model.activeSolution?.name}: startup project`, matchOnDescription: true });
if (picked) {
await model.selectProject(picked.project);
}
}
async function pickConfiguration(): Promise<void> {
const project = model.startupProject;
const configurations = model.configurations;
if (!project || configurations.length === 0) {
return;
}
const picked = await vscode.window.showQuickPick(
configurations.map(config => {
const mapped = projectConfigurationFor(project.solutionProject, config);
const natural = model.isNaturalConfiguration(config);
return {
label: `${natural ? '' : '$(warning) '}${config.configuration} | ${config.platform}`,
description: mapped.build
? `${project.info.name}${mapped.configuration}|${mapped.platform}`
: `${project.info.name} is not built under this configuration`,
picked: config === model.activeConfiguration,
config,
};
}),
{ title: `${project.info.name}: solution configuration`, matchOnDescription: true });
if (picked) {
await model.selectConfiguration(picked.config);
}
}
async function pickSolution(): Promise<void> {
const solutions = model.allSolutions;
if (solutions.length === 0) {
void vscode.window.showWarningMessage('No .sln or .slnx found in the workspace.');
return;
}
const picked = await vscode.window.showQuickPick(
solutions.map(solution => ({
label: solution.name,
description: vscode.workspace.asRelativePath(solution.fsPath, false),
detail: `${solution.projects.length} project(s), ${solution.configurations.length} configuration(s)`,
picked: solution === model.activeSolution,
solution,
})),
{ title: 'Solution to use', matchOnDescription: true });
if (picked) {
await model.selectSolution(picked.solution);
}
}
/** Builds with the active selection and reports the exit code; -1 when nothing could be built. */
async function build(target: BuildTarget): Promise<number> {
const task = createTask(model, { type: TASK_TYPE, target });
if (!task) {
void vscode.window.showWarningMessage('No startup project selected — nothing to build.');
return -1;
}
const active = model.active!;
status.setBusy(`${target} ${active.solutionConfiguration.configuration}|${active.solutionPlatform}`);
try {
const code = await runTask(task);
if (code !== 0) {
void vscode.window.showErrorMessage(
`${task.name} failed (exit code ${code}). See the terminal or the Problems panel.`);
}
return code;
} finally {
status.setBusy(undefined);
}
}
async function launch(noDebug: boolean): Promise<void> {
if (!model.active) {
await pickStartupProject();
if (!model.active) {
return;
}
}
if (await build('build') !== 0) {
return;
}
try {
const configuration = await buildLaunchConfiguration(model, { noDebug });
log.appendLine(`Launching: ${JSON.stringify(configuration)}`);
const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(model.active.solution.fsPath));
const started = await vscode.debug.startDebugging(folder, configuration);
if (!started) {
void vscode.window.showErrorMessage(
`The ${configuration.type} debugger did not start. Is DotRush (or another coreclr adapter) installed?`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.appendLine(message);
void vscode.window.showErrorMessage(message, 'Show Log').then(choice => choice && log.show());
}
}
/** Adds this extension's entries to .vscode/launch.json and tasks.json, replacing older ones by name. */
async function generateLaunchConfig(): Promise<void> {
const active = model.active;
const folder = active
? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(active.solution.fsPath))
: vscode.workspace.workspaceFolders?.[0];
if (!folder) {
void vscode.window.showWarningMessage('Open a folder first.');
return;
}
const launch = vscode.workspace.getConfiguration('launch', folder.uri);
const configurations = (launch.get<Record<string, unknown>[]>('configurations') ?? [])
.filter(entry => entry.name !== LAUNCH_NAME);
configurations.unshift(launchJsonEntry());
await launch.update('configurations', configurations, vscode.ConfigurationTarget.WorkspaceFolder);
if (!launch.get('version')) {
await launch.update('version', '0.2.0', vscode.ConfigurationTarget.WorkspaceFolder);
}
const tasks = vscode.workspace.getConfiguration('tasks', folder.uri);
const existing = (tasks.get<Record<string, unknown>[]>('tasks') ?? [])
.filter(entry => entry.type !== TASK_TYPE);
const targets: BuildTarget[] = ['build', 'rebuild', 'clean'];
for (const target of targets) {
existing.push({
label: taskLabel(target),
type: TASK_TYPE,
target,
problemMatcher: '$msCompile',
...(target === 'build' ? { group: { kind: 'build', isDefault: true } } : {}),
});
}
await tasks.update('tasks', existing, vscode.ConfigurationTarget.WorkspaceFolder);
if (!tasks.get('version')) {
await tasks.update('version', '2.0.0', vscode.ConfigurationTarget.WorkspaceFolder);
}
const choice = await vscode.window.showInformationMessage(
`Added "${LAUNCH_NAME}" to launch.json and ${TASK_TYPE} tasks to tasks.json.`, 'Open launch.json');
if (choice) {
await vscode.window.showTextDocument(vscode.Uri.joinPath(folder.uri, '.vscode', 'launch.json'));
}
}
// ---- DotRush interop -------------------------------------------------------------
interface DotRushExports {
onActiveProjectChanged?: { add(callback: (project: { path: string; name: string }) => void): void };
}
let syncingFromDotRush = false;
/**
* Keeps DotRush's startup project equal to ours, both ways.
*
* DotRush cannot be told the configuration or platform (its selection lives in its own
* workspace state), which is why this extension owns the build instead of pointing at
* `dotrush: Build`. But the *project* can be pushed through `dotrush.setStartupProject`,
* and pulled back through its exports, so the two status bar items never disagree about
* which .csproj is meant.
*/
function connectDotRush(context: vscode.ExtensionContext): void {
const dotrush = vscode.extensions.getExtension<DotRushExports>('nromanov.dotrush');
if (!dotrush) {
log.appendLine('DotRush is not installed; nothing to sync.');
return;
}
void dotrush.activate().then(exports => {
exports?.onActiveProjectChanged?.add(project => {
if (!settings().get<boolean>('syncDotRush', true) || !project?.path) {
return;
}
syncingFromDotRush = true;
void model.selectProjectByPath(project.path).finally(() => { syncingFromDotRush = false; });
});
}, error => log.appendLine(`DotRush did not activate: ${error}`));
context.subscriptions.push(model.onDidChange(() => {
const project = model.startupProject;
if (syncingFromDotRush || !project || !settings().get<boolean>('syncDotRush', true)) {
return;
}
void vscode.commands.executeCommand('dotrush.setStartupProject', vscode.Uri.file(project.info.fsPath))
.then(undefined, error => log.appendLine(`dotrush.setStartupProject failed: ${error}`));
}));
context.subscriptions.push(model.onDidChange(() => void syncWorkspaceProperties()));
}
/** Optionally mirrors Configuration/Platform into DotRush's Roslyn workspace properties. */
async function syncWorkspaceProperties(): Promise<void> {
if (!settings().get<boolean>('syncDotRushWorkspaceProperties', false)) {
return;
}
const active = model.active;
if (!active) {
return;
}
const roslyn = vscode.workspace.getConfiguration('dotrush.roslyn');
const current = roslyn.get<string[]>('workspaceProperties', []);
const kept = current.filter(entry => !/^(Configuration|Platform)=/i.test(entry));
const next = [...kept, `Configuration=${active.projectConfiguration}`, `Platform=${active.projectPlatform}`];
if (JSON.stringify(next) !== JSON.stringify(current)) {
await roslyn.update('workspaceProperties', next, vscode.ConfigurationTarget.Workspace);
}
}
// ---- activation -------------------------------------------------------------------
export function activate(context: vscode.ExtensionContext): void {
log = vscode.window.createOutputChannel('.NET Solution');
model = new SolutionModel(context.workspaceState, log);
status = new StatusBar(model);
const active = () => model.active;
const command = (id: string, handler: (...args: unknown[]) => unknown) =>
vscode.commands.registerCommand(id, handler);
context.subscriptions.push(
log, model, status,
vscode.tasks.registerTaskProvider(TASK_TYPE, new SolutionTaskProvider(model)),
vscode.debug.registerDebugConfigurationProvider(
settings().get<string>('debugType', 'coreclr'),
new SolutionDebugConfigurationProvider(model),
vscode.DebugConfigurationProviderTriggerKind.Dynamic),
command('dotnetSolution.selectStartupProject', pickStartupProject),
command('dotnetSolution.selectConfiguration', pickConfiguration),
command('dotnetSolution.selectSolution', pickSolution),
command('dotnetSolution.build', () => build('build')),
command('dotnetSolution.rebuild', () => build('rebuild')),
command('dotnetSolution.clean', () => build('clean')),
command('dotnetSolution.debug', () => launch(false)),
command('dotnetSolution.run', () => launch(true)),
command('dotnetSolution.generateLaunchConfig', generateLaunchConfig),
command('dotnetSolution.reload', () => model.reload()),
command('dotnetSolution.showOutput', () => log.show()),
command('dotnetSolution.setStartupProject', async (resource?: unknown) => {
const uri = resource instanceof vscode.Uri ? resource : undefined;
if (uri && !(await model.selectProjectByPath(uri.fsPath))) {
void vscode.window.showWarningMessage(
`${path.basename(uri.fsPath)} is not an executable project of ${model.activeSolution?.name ?? 'the solution'}.`);
} else if (!uri) {
await pickStartupProject();
}
}),
// Variables for launch.json / tasks.json: ${command:dotnetSolution.xxx}
command('dotnetSolution.activeProgram', async () => {
const target = await model.resolveTarget();
return target.executablePath ?? target.targetPath;
}),
command('dotnetSolution.activeTargetPath', async () => (await model.resolveTarget()).targetPath),
command('dotnetSolution.activeTargetDir', async () => (await model.resolveTarget()).targetDir.replace(/[\\/]+$/, '')),
command('dotnetSolution.activeProjectPath', () => active()?.project.info.fsPath),
command('dotnetSolution.activeProjectName', () => active()?.project.info.name),
command('dotnetSolution.activeSolutionPath', () => active()?.solution.fsPath),
command('dotnetSolution.activeConfiguration', () => active()?.solutionConfiguration.configuration),
command('dotnetSolution.activePlatform', () => active()?.solutionPlatform),
command('dotnetSolution.activeProjectConfiguration', () => active()?.projectConfiguration),
command('dotnetSolution.activeProjectPlatform', () => active()?.projectPlatform),
command('dotnetSolution.activeConfigurationKey', () => {
const config: SolutionConfiguration | undefined = active()?.solutionConfiguration;
return config ? configurationKey(config.configuration, config.platform) : undefined;
}),
vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('dotnetSolution')) {
void model.reload();
}
}),
);
connectDotRush(context);
void model.reload().then(() => offerLaunchJson(context));
}
/** Once per workspace: if there is a solution but no launch.json entry of ours, offer to add one. */
async function offerLaunchJson(context: vscode.ExtensionContext): Promise<void> {
const KEY = 'dotnetSolution.offeredLaunchJson';
if (!model.active || context.workspaceState.get<boolean>(KEY)) {
return;
}
await context.workspaceState.update(KEY, true);
const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(model.active.solution.fsPath));
const configurations = vscode.workspace.getConfiguration('launch', folder?.uri).get<{ name?: string }[]>('configurations') ?? [];
if (configurations.some(entry => entry.name === LAUNCH_NAME)) {
return;
}
const project: StartupProject = model.active.project;
const choice = await vscode.window.showInformationMessage(
`Found ${model.active.solution.name}.sln with startup project ${project.info.name}. ` +
'Add a launch.json entry that builds with the selected solution configuration?',
'Add', 'Not now');
if (choice === 'Add') {
await generateLaunchConfig();
}
}
export function deactivate(): void { }
+85
View File
@@ -0,0 +1,85 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { SolutionModel } from './model';
import { taskLabel } from './tasks';
export const LAUNCH_NAME = '.NET Solution: Debug startup project';
function launchSettings() {
return vscode.workspace.getConfiguration('dotnetSolution');
}
/**
* A complete launch configuration for the startup project under the active configuration.
*
* `program` is the apphost .exe when the project makes one, because vsdbg wants an
* executable. Projects without an apphost run through the dotnet host instead. The
* debugger options DotRush normally fills in (justMyCode, symbol servers, console) are
* left out so its own provider still adds them.
*/
export async function buildLaunchConfiguration(
model: SolutionModel, options: { noDebug?: boolean; preLaunchTask?: string }): Promise<vscode.DebugConfiguration> {
const active = model.active;
if (!active) {
throw new Error('No startup project selected.');
}
const target = await model.resolveTarget();
const settings = launchSettings();
const userArgs = settings.get<string[]>('launch.args', []);
const cwd = settings.get<string>('launch.cwd', '') || target.targetDir.replace(/[\\/]+$/, '');
const console = settings.get<string>('launch.console', 'internalConsole');
const program = target.executablePath ?? 'dotnet';
const args = target.executablePath ? userArgs : [target.targetPath, ...userArgs];
return {
name: `${active.project.info.name} (${active.solutionConfiguration.configuration}|${active.solutionPlatform})`,
type: settings.get<string>('debugType', 'coreclr'),
request: 'launch',
program,
args,
cwd,
env: settings.get<Record<string, string>>('launch.env', {}),
console,
noDebug: options.noDebug ?? false,
preLaunchTask: options.preLaunchTask,
// DotRush looks for Properties/launchSettings.json next to *its* startup project;
// this one is synced, so that still works. Point it explicitly anyway.
launchSettingsFilePath: launchSettingsPath(active.project.info.fsPath),
};
}
function launchSettingsPath(projectPath: string): string | undefined {
const candidate = path.join(path.dirname(projectPath), 'Properties', 'launchSettings.json');
return fs.existsSync(candidate) ? candidate : undefined;
}
/** The static entry written to launch.json: values come back through `${command:…}` at launch time. */
export function launchJsonEntry(): Record<string, unknown> {
return {
name: LAUNCH_NAME,
type: launchSettings().get<string>('debugType', 'coreclr'),
request: 'launch',
program: '${command:dotnetSolution.activeProgram}',
args: [],
cwd: '${command:dotnetSolution.activeTargetDir}',
preLaunchTask: taskLabel('build'),
};
}
/**
* Offers the dynamic "debug the startup project" entry in the Run and Debug dropdown,
* and fills in `program` for launch.json entries that use this extension's variables
* but omitted them.
*/
export class SolutionDebugConfigurationProvider implements vscode.DebugConfigurationProvider {
constructor(private readonly model: SolutionModel) { }
async provideDebugConfigurations(): Promise<vscode.DebugConfiguration[]> {
if (!this.model.active) {
return [];
}
return [launchJsonEntry() as vscode.DebugConfiguration];
}
}
+298
View File
@@ -0,0 +1,298 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { parseProject, ProjectInfo } from './csproj';
import { evaluateTarget, TargetInfo } from './msbuild';
import {
configurationKey, msbuildPlatform, parseConfigurationKey, parseSolution,
projectConfigurationFor, Solution, SolutionConfiguration, SolutionProject,
} from './sln';
const decoder = new TextDecoder('utf-8');
export interface StartupProject {
solutionProject: SolutionProject;
info: ProjectInfo;
}
/** Everything the build and launch need, resolved from the current selection. */
export interface ActiveTarget {
solution: Solution;
project: StartupProject;
/** The solution configuration shown in the status bar, e.g. Debug|x64. */
solutionConfiguration: SolutionConfiguration;
/** What that maps to for the startup project, in MSBuild spelling (AnyCPU). */
projectConfiguration: string;
projectPlatform: string;
/** The solution's own `Any CPU` spelling, for `-p:Platform` on a solution build. */
solutionPlatform: string;
targetFramework: string | undefined;
}
const STATE_SOLUTION = 'dotnetSolution.solution';
const STATE_PROJECT = 'dotnetSolution.project';
const STATE_CONFIGURATION = 'dotnetSolution.configuration';
/**
* The solution, its startup project and the selected configuration.
*
* The selection is stored per workspace, keyed by paths, so reopening the folder keeps
* it. Everything derived from files is re-read when a solution or project file changes.
*/
export class SolutionModel implements vscode.Disposable {
private readonly changed = new vscode.EventEmitter<void>();
readonly onDidChange = this.changed.event;
private solutions: Solution[] = [];
private solution: Solution | undefined;
private projects: StartupProject[] = [];
private project: StartupProject | undefined;
private configuration: SolutionConfiguration | undefined;
private readonly targets = new Map<string, Promise<TargetInfo>>();
private readonly watcher: vscode.FileSystemWatcher;
private reloadTimer: NodeJS.Timeout | undefined;
constructor(
private readonly state: vscode.Memento,
private readonly log: vscode.OutputChannel,
) {
this.watcher = vscode.workspace.createFileSystemWatcher('**/*.{sln,slnx,csproj,fsproj,vbproj,props,targets}');
const schedule = () => {
clearTimeout(this.reloadTimer);
this.reloadTimer = setTimeout(() => void this.reload(), 500);
};
this.watcher.onDidChange(schedule);
this.watcher.onDidCreate(schedule);
this.watcher.onDidDelete(schedule);
}
dispose(): void {
clearTimeout(this.reloadTimer);
this.watcher.dispose();
this.changed.dispose();
}
get activeSolution(): Solution | undefined { return this.solution; }
get allSolutions(): Solution[] { return this.solutions; }
get startupProject(): StartupProject | undefined { return this.project; }
/** Executable projects of the active solution, sorted by name. */
get startupCandidates(): StartupProject[] { return this.projects; }
get activeConfiguration(): SolutionConfiguration | undefined { return this.configuration; }
/** Solution configurations, with the ones that actually build the startup project first. */
get configurations(): SolutionConfiguration[] {
if (!this.solution) {
return [];
}
const project = this.project?.solutionProject;
const declared = new Set(this.project?.info.platforms.map(msbuildPlatform) ?? []);
const score = (config: SolutionConfiguration): number => {
if (!project) {
return 0;
}
const mapped = projectConfigurationFor(project, config);
let value = mapped.build ? 0 : 2;
if (declared.size > 0 && !declared.has(msbuildPlatform(mapped.platform))) {
value += 1;
}
return value;
};
return [...this.solution.configurations].sort((a, b) => score(a) - score(b));
}
/** True when the solution maps this configuration onto a platform the startup project declares. */
isNaturalConfiguration(config: SolutionConfiguration): boolean {
const project = this.project;
if (!project) {
return true;
}
const mapped = projectConfigurationFor(project.solutionProject, config);
const declared = project.info.platforms.map(msbuildPlatform);
return mapped.build && (declared.length === 0 || declared.includes(msbuildPlatform(mapped.platform)));
}
get active(): ActiveTarget | undefined {
if (!this.solution || !this.project || !this.configuration) {
return undefined;
}
const mapped = projectConfigurationFor(this.project.solutionProject, this.configuration);
const configured = vscode.workspace.getConfiguration('dotnetSolution').get<string>('targetFramework', '');
const frameworks = this.project.info.targetFrameworks;
const targetFramework = this.project.info.multiTargeting
? (configured && frameworks.includes(configured) ? configured : frameworks[0])
: undefined;
return {
solution: this.solution,
project: this.project,
solutionConfiguration: this.configuration,
projectConfiguration: mapped.configuration,
projectPlatform: msbuildPlatform(mapped.platform),
solutionPlatform: this.configuration.platform,
targetFramework,
};
}
async reload(): Promise<void> {
this.targets.clear();
this.solutions = await discoverSolutions(this.log);
this.solution = this.pickSolution();
this.projects = this.solution ? await loadStartupCandidates(this.solution, this.log) : [];
this.project = this.pickProject();
this.configuration = this.pickConfiguration();
this.log.appendLine(
`Loaded ${this.solutions.length} solution(s); active: ${this.solution?.name ?? 'none'}, ` +
`startup: ${this.project?.info.name ?? 'none'}, ` +
`configuration: ${this.configuration ? configurationKey(this.configuration.configuration, this.configuration.platform) : 'none'}`);
this.changed.fire();
}
async selectSolution(solution: Solution): Promise<void> {
await this.state.update(STATE_SOLUTION, solution.fsPath);
await this.reload();
}
async selectProject(project: StartupProject): Promise<void> {
this.project = project;
await this.state.update(STATE_PROJECT, project.info.fsPath);
// A different project may map the same solution configuration differently, or not
// declare the platform at all; keep the selection but re-validate it.
this.configuration = this.pickConfiguration();
this.changed.fire();
}
/** Selects the startup project by path, e.g. from the explorer or from DotRush. */
async selectProjectByPath(fsPath: string): Promise<boolean> {
const normalized = path.normalize(fsPath).toLowerCase();
const match = this.projects.find(project => path.normalize(project.info.fsPath).toLowerCase() === normalized);
if (!match) {
return false;
}
if (match !== this.project) {
await this.selectProject(match);
}
return true;
}
async selectConfiguration(config: SolutionConfiguration): Promise<void> {
this.configuration = config;
await this.state.update(STATE_CONFIGURATION, configurationKey(config.configuration, config.platform));
this.changed.fire();
}
/** Where the startup project's output lands under the active configuration. Cached until files change. */
resolveTarget(): Promise<TargetInfo> {
const active = this.active;
if (!active) {
return Promise.reject(new Error('No startup project selected.'));
}
const key = [active.project.info.fsPath, active.projectConfiguration, active.projectPlatform, active.targetFramework ?? ''].join('|');
let pending = this.targets.get(key);
if (!pending) {
pending = evaluateTarget({
projectPath: active.project.info.fsPath,
configuration: active.projectConfiguration,
platform: active.projectPlatform,
targetFramework: active.targetFramework,
}, this.log);
pending.catch(() => this.targets.delete(key));
this.targets.set(key, pending);
}
return pending;
}
private pickSolution(): Solution | undefined {
if (this.solutions.length === 0) {
return undefined;
}
const configured = vscode.workspace.getConfiguration('dotnetSolution').get<string>('solution', '');
const wanted = configured ? resolveWorkspacePath(configured) : this.state.get<string>(STATE_SOLUTION);
if (wanted) {
const match = this.solutions.find(solution => samePath(solution.fsPath, wanted));
if (match) {
return match;
}
if (configured) {
void vscode.window.showWarningMessage(`dotnetSolution.solution points at "${configured}", which was not found.`);
}
}
// Closest to the workspace root wins; among equals the one with more projects.
return [...this.solutions].sort((a, b) =>
depth(a.fsPath) - depth(b.fsPath) || b.projects.length - a.projects.length)[0];
}
private pickProject(): StartupProject | undefined {
if (this.projects.length === 0) {
return undefined;
}
const remembered = this.state.get<string>(STATE_PROJECT);
const match = remembered ? this.projects.find(project => samePath(project.info.fsPath, remembered)) : undefined;
return match ?? this.projects[0];
}
private pickConfiguration(): SolutionConfiguration | undefined {
const configurations = this.configurations;
if (configurations.length === 0) {
return undefined;
}
const remembered = this.state.get<string>(STATE_CONFIGURATION);
if (remembered) {
const wanted = parseConfigurationKey(remembered);
const match = configurations.find(config =>
config.configuration === wanted.configuration && config.platform === wanted.platform);
if (match) {
return match;
}
}
// Debug on a platform the project declares, otherwise the best-scored one.
return configurations.find(config => /^debug$/i.test(config.configuration) && this.isNaturalConfiguration(config))
?? configurations[0];
}
}
function depth(fsPath: string): number {
return vscode.workspace.asRelativePath(fsPath, false).split(/[\\/]/).length;
}
function samePath(a: string, b: string): boolean {
return path.normalize(a).toLowerCase() === path.normalize(b).toLowerCase();
}
function resolveWorkspacePath(relative: string): string {
if (path.isAbsolute(relative)) {
return relative;
}
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
return path.join(root, relative);
}
async function discoverSolutions(log: vscode.OutputChannel): Promise<Solution[]> {
const files = await vscode.workspace.findFiles('**/*.{sln,slnx}', '**/{bin,obj,node_modules,.git}/**');
const solutions: Solution[] = [];
for (const uri of files) {
try {
const text = decoder.decode(await vscode.workspace.fs.readFile(uri));
const solution = parseSolution(text, uri.fsPath);
if (solution.projects.length > 0) {
solutions.push(solution);
}
} catch (error) {
log.appendLine(`Could not read ${uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return solutions;
}
async function loadStartupCandidates(solution: Solution, log: vscode.OutputChannel): Promise<StartupProject[]> {
const loaded = await Promise.all(solution.projects.map(async solutionProject => {
try {
const xml = decoder.decode(await vscode.workspace.fs.readFile(vscode.Uri.file(solutionProject.fsPath)));
const info = parseProject(xml, solutionProject.fsPath);
return info.executable ? { solutionProject, info } satisfies StartupProject : undefined;
} catch (error) {
log.appendLine(`Could not read ${solutionProject.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}));
return loaded
.filter((project): project is StartupProject => project !== undefined)
.sort((a, b) => a.info.name.localeCompare(b.info.name));
}
+98
View File
@@ -0,0 +1,98 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { execFile } from 'child_process';
/** Evaluated output properties of a project under one configuration. */
export interface TargetInfo {
/** The built assembly, usually a .dll. */
targetPath: string;
targetDir: string;
assemblyName: string;
/** The apphost executable next to the dll, when the project produces one. */
executablePath: string | undefined;
/** The framework the evaluation used, when the project multi-targets. */
targetFramework: string | undefined;
}
export interface EvaluationRequest {
projectPath: string;
configuration: string;
/** MSBuild spelling: `AnyCPU`, not `Any CPU`. */
platform: string;
targetFramework?: string;
}
const PROPERTIES = ['TargetPath', 'TargetDir', 'AssemblyName', 'UseAppHost', 'OutputType', 'TargetExt'];
export function dotnetPath(): string {
// DotRush has its own SDK directory setting; honour it so both agree on the SDK.
const sdkDir = vscode.workspace.getConfiguration('dotrush.roslyn').get<string>('dotnetSdkDirectory', '');
if (sdkDir) {
// The setting points at dotnet/sdk/<version>; the host is two levels up.
const host = path.join(sdkDir, '..', '..', process.platform === 'win32' ? 'dotnet.exe' : 'dotnet');
return path.normalize(host);
}
return 'dotnet';
}
function run(args: string[], cwd: string, log: vscode.OutputChannel): Promise<string> {
const command = dotnetPath();
log.appendLine(`> ${command} ${args.join(' ')}`);
return new Promise((resolve, reject) => {
execFile(command, args, { cwd, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
log.appendLine(stdout);
log.appendLine(stderr);
reject(new Error(`${command} ${args[0]} failed: ${error.message}`));
return;
}
resolve(stdout);
});
});
}
/**
* Evaluates the project without building it, the way `-getProperty` does, and returns
* where the output lands under the requested configuration.
*
* Reading the csproj would not do: OutDir here comes from a Directory.Build.props, and
* AppendTargetFrameworkToOutputPath is off, so only MSBuild knows the answer.
*/
export async function evaluateTarget(request: EvaluationRequest, log: vscode.OutputChannel): Promise<TargetInfo> {
const args = [
'msbuild',
request.projectPath,
'-nologo',
`-p:Configuration=${request.configuration}`,
`-p:Platform=${request.platform}`,
...(request.targetFramework ? [`-p:TargetFramework=${request.targetFramework}`] : []),
...PROPERTIES.map(name => `-getProperty:${name}`),
];
const stdout = await run(args, path.dirname(request.projectPath), log);
let properties: Record<string, string>;
try {
// With several -getProperty switches the output is JSON: { "Properties": { ... } }.
const start = stdout.indexOf('{');
properties = JSON.parse(stdout.slice(start)).Properties ?? {};
} catch {
throw new Error(`Could not read MSBuild properties for ${request.projectPath}:\n${stdout}`);
}
const targetPath = properties.TargetPath ?? '';
if (!targetPath) {
throw new Error(
`MSBuild returned no TargetPath for ${path.basename(request.projectPath)} ` +
`(${request.configuration}|${request.platform}). Multi-targeting projects need a TargetFramework.`);
}
const targetDir = properties.TargetDir || path.dirname(targetPath) + path.sep;
const assemblyName = properties.AssemblyName || path.basename(targetPath, path.extname(targetPath));
const executable = /^(win)?exe$/i.test((properties.OutputType ?? '').trim());
const useAppHost = (properties.UseAppHost ?? 'true').trim().toLowerCase() !== 'false';
const executablePath = executable && useAppHost
? path.join(targetDir, assemblyName + (process.platform === 'win32' ? '.exe' : ''))
: undefined;
return { targetPath, targetDir, assemblyName, executablePath, targetFramework: request.targetFramework };
}
+243
View File
@@ -0,0 +1,243 @@
import * as path from 'path';
/** One `Configuration|Platform` pair as written in a solution file, e.g. `Debug|x64`. */
export interface SolutionConfiguration {
configuration: string;
/** As written in the solution: `x64`, `Any CPU`, `x86`. */
platform: string;
}
/** What the solution tells MSBuild to do with one project under one solution configuration. */
export interface ProjectConfiguration {
configuration: string;
/** As written in the solution (`Any CPU`, with a space). See `msbuildPlatform`. */
platform: string;
/** True when the solution builds this project under that configuration (`.Build.0`). */
build: boolean;
}
export interface SolutionProject {
name: string;
/** Absolute path to the project file. */
fsPath: string;
guid: string;
/** Project GUIDs this project depends on at solution level (ProjectDependencies). */
dependencies: string[];
/** Keyed by `configuration|platform` of the *solution* configuration. */
configurations: Map<string, ProjectConfiguration>;
}
export interface Solution {
fsPath: string;
name: string;
projects: SolutionProject[];
/** In file order, deduplicated. */
configurations: SolutionConfiguration[];
}
/** Solution folders are listed as projects but have no file. */
const SOLUTION_FOLDER_TYPE = '{2150E333-8FDC-42A3-9474-1A3956D46DE8}';
export function configurationKey(configuration: string, platform: string): string {
return `${configuration}|${platform}`;
}
export function parseConfigurationKey(key: string): SolutionConfiguration {
const bar = key.indexOf('|');
return bar < 0
? { configuration: key, platform: 'Any CPU' }
: { configuration: key.slice(0, bar), platform: key.slice(bar + 1) };
}
/** The solution says `Any CPU`; a project built on its own says `AnyCPU`. */
export function msbuildPlatform(platform: string): string {
return platform.replace(/\s+/g, '');
}
/**
* Parses the classic `.sln` text format.
*
* Only the parts a launcher needs: projects, solution configurations, the mapping from
* solution configuration to project configuration, and solution-level dependencies.
*/
export function parseSln(text: string, slnPath: string): Solution {
const dir = path.dirname(slnPath);
const projects: SolutionProject[] = [];
const byGuid = new Map<string, SolutionProject>();
const configurations: SolutionConfiguration[] = [];
const seenConfigurations = new Set<string>();
let current: SolutionProject | undefined;
let section: 'none' | 'dependencies' | 'solutionConfigs' | 'projectConfigs' = 'none';
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
const project = /^Project\("(\{[^}]+\})"\)\s*=\s*"([^"]*)",\s*"([^"]*)",\s*"(\{[^}]+\})"/.exec(line);
if (project) {
const [, typeGuid, name, relative, guid] = project;
current = undefined;
if (typeGuid.toUpperCase() === SOLUTION_FOLDER_TYPE || !/\.\w*proj$/i.test(relative)) {
continue;
}
current = {
name,
fsPath: path.resolve(dir, relative.replace(/\\/g, path.sep)),
guid: guid.toUpperCase(),
dependencies: [],
configurations: new Map(),
};
projects.push(current);
byGuid.set(current.guid, current);
continue;
}
if (line === 'EndProject') {
current = undefined;
continue;
}
if (/^ProjectSection\(ProjectDependencies\)/.test(line)) {
section = 'dependencies';
continue;
}
if (/^GlobalSection\(SolutionConfigurationPlatforms\)/.test(line)) {
section = 'solutionConfigs';
continue;
}
if (/^GlobalSection\(ProjectConfigurationPlatforms\)/.test(line)) {
section = 'projectConfigs';
continue;
}
if (line === 'EndProjectSection' || line === 'EndGlobalSection') {
section = 'none';
continue;
}
switch (section) {
case 'dependencies': {
const dependency = /^(\{[^}]+\})\s*=/.exec(line);
if (dependency && current) {
current.dependencies.push(dependency[1].toUpperCase());
}
break;
}
case 'solutionConfigs': {
const config = /^([^=]+?)\s*=\s*(.+)$/.exec(line);
if (config) {
const parsed = parseConfigurationKey(config[1].trim());
const key = configurationKey(parsed.configuration, parsed.platform);
if (!seenConfigurations.has(key)) {
seenConfigurations.add(key);
configurations.push(parsed);
}
}
break;
}
case 'projectConfigs': {
// {GUID}.Debug|x64.ActiveCfg = Debug|x64
// {GUID}.Debug|x64.Build.0 = Debug|x64
const mapping = /^(\{[^}]+\})\.(.+?)\.(ActiveCfg|Build\.0|Deploy\.0)\s*=\s*(.+)$/.exec(line);
if (!mapping) {
break;
}
const target = byGuid.get(mapping[1].toUpperCase());
if (!target) {
break;
}
const solutionKey = mapping[2].trim();
const mapped = parseConfigurationKey(mapping[4].trim());
const existing = target.configurations.get(solutionKey);
if (mapping[3] === 'ActiveCfg') {
target.configurations.set(solutionKey, {
configuration: mapped.configuration,
platform: mapped.platform,
build: existing?.build ?? false,
});
} else if (mapping[3] === 'Build.0') {
target.configurations.set(solutionKey, {
configuration: existing?.configuration ?? mapped.configuration,
platform: existing?.platform ?? mapped.platform,
build: true,
});
}
break;
}
}
}
return {
fsPath: slnPath,
name: path.basename(slnPath, path.extname(slnPath)),
projects,
configurations,
};
}
/**
* Parses the XML `.slnx` format (SDK 9+).
*
* `.slnx` has no per-project configuration mapping unless a project lists explicit
* `<BuildType>`/`<Platform>` overrides, so every project maps to the solution
* configuration as-is, which is also what MSBuild does.
*/
export function parseSlnx(text: string, slnxPath: string): Solution {
const dir = path.dirname(slnxPath);
const projects: SolutionProject[] = [];
const buildTypes = [...text.matchAll(/<BuildType\s+Name="([^"]+)"/g)].map(m => m[1]);
const platforms = [...text.matchAll(/<Platform\s+Name="([^"]+)"/g)].map(m => m[1]);
const configurations: SolutionConfiguration[] = [];
for (const configuration of buildTypes.length ? buildTypes : ['Debug', 'Release']) {
for (const platform of platforms.length ? platforms : ['Any CPU']) {
configurations.push({ configuration, platform });
}
}
let index = 0;
for (const match of text.matchAll(/<Project\s+([^>]*?)\/?>/g)) {
const attributes = match[1];
const relative = /\bPath="([^"]+)"/.exec(attributes)?.[1];
if (!relative || !/\.\w*proj$/i.test(relative)) {
continue;
}
const fsPath = path.resolve(dir, relative.replace(/\\/g, path.sep));
const project: SolutionProject = {
name: path.basename(relative, path.extname(relative)),
fsPath,
guid: `{SLNX-${index++}}`,
dependencies: [],
configurations: new Map(),
};
for (const config of configurations) {
project.configurations.set(configurationKey(config.configuration, config.platform), {
configuration: config.configuration,
platform: config.platform,
build: true,
});
}
projects.push(project);
}
return {
fsPath: slnxPath,
name: path.basename(slnxPath, path.extname(slnxPath)),
projects,
configurations,
};
}
export function parseSolution(text: string, fsPath: string): Solution {
return /\.slnx$/i.test(fsPath) ? parseSlnx(text, fsPath) : parseSln(text, fsPath);
}
/**
* The project configuration a solution configuration maps a project to.
*
* Falls back to the solution configuration itself when the solution has no mapping,
* which is what MSBuild does too (with a warning).
*/
export function projectConfigurationFor(
project: SolutionProject, solutionConfig: SolutionConfiguration): ProjectConfiguration {
const key = configurationKey(solutionConfig.configuration, solutionConfig.platform);
return project.configurations.get(key)
?? { configuration: solutionConfig.configuration, platform: solutionConfig.platform, build: false };
}
+108
View File
@@ -0,0 +1,108 @@
import * as vscode from 'vscode';
import { SolutionModel } from './model';
import { taskLabel } from './tasks';
/**
* Two status bar items, left side, next to DotRush's own:
*
* $(project) MyGame.Editor $(settings-gear) Debug | x64 $(debug-alt)
*
* The first picks the startup project, the second the solution configuration, the third
* launches. The project name is the point: DotRush shows only the configuration, so with
* three executables in the solution it is never clear which one F5 will build.
*/
export class StatusBar implements vscode.Disposable {
private readonly projectItem: vscode.StatusBarItem;
private readonly configurationItem: vscode.StatusBarItem;
private readonly debugItem: vscode.StatusBarItem;
private busy: string | undefined;
constructor(private readonly model: SolutionModel) {
this.projectItem = vscode.window.createStatusBarItem('dotnetSolution.project', vscode.StatusBarAlignment.Left, 101);
this.projectItem.name = '.NET Solution: Startup Project';
this.projectItem.command = 'dotnetSolution.selectStartupProject';
this.configurationItem = vscode.window.createStatusBarItem('dotnetSolution.configuration', vscode.StatusBarAlignment.Left, 100.5);
this.configurationItem.name = '.NET Solution: Configuration';
this.configurationItem.command = 'dotnetSolution.selectConfiguration';
this.debugItem = vscode.window.createStatusBarItem('dotnetSolution.debug', vscode.StatusBarAlignment.Left, 100.4);
this.debugItem.name = '.NET Solution: Debug';
this.debugItem.command = 'dotnetSolution.debug';
this.debugItem.text = '$(debug-alt)';
model.onDidChange(() => this.render());
this.render();
}
/** Shows a spinner with a message while a build runs. */
setBusy(message: string | undefined): void {
this.busy = message;
this.render();
}
render(): void {
const active = this.model.active;
if (!active) {
const solutions = this.model.allSolutions.length;
if (solutions === 0) {
this.projectItem.hide();
this.configurationItem.hide();
this.debugItem.hide();
return;
}
this.projectItem.text = '$(project) No startup project';
this.projectItem.tooltip = 'No executable project in the solution. Click to pick a solution.';
this.projectItem.command = 'dotnetSolution.selectSolution';
this.projectItem.show();
this.configurationItem.hide();
this.debugItem.hide();
return;
}
const { project, solution, solutionConfiguration } = active;
this.projectItem.command = 'dotnetSolution.selectStartupProject';
this.projectItem.text = this.busy
? `$(loading~spin) ${project.info.name}`
: `$(project) ${project.info.name}`;
this.projectItem.tooltip = new vscode.MarkdownString([
`**Startup project:** \`${project.info.name}\``,
`${vscode.workspace.asRelativePath(project.info.fsPath, false)}`,
'',
`**Solution:** \`${solution.name}\` (${vscode.workspace.asRelativePath(solution.fsPath, false)})`,
this.busy ? `\n$(loading~spin) ${this.busy}` : '',
'',
'Click to change the startup project.',
].join(' \n'));
const mapped = `${active.projectConfiguration}|${active.projectPlatform}`;
const shown = `${solutionConfiguration.configuration} | ${solutionConfiguration.platform}`;
const natural = this.model.isNaturalConfiguration(solutionConfiguration);
this.configurationItem.text = `$(settings-gear) ${shown}${natural ? '' : ' $(warning)'}`;
this.configurationItem.backgroundColor = natural ? undefined : new vscode.ThemeColor('statusBarItem.warningBackground');
this.configurationItem.tooltip = new vscode.MarkdownString([
`**Solution configuration:** \`${shown}\``,
`Builds \`${project.info.name}\` as \`${mapped}\`` +
(active.targetFramework ? ` (${active.targetFramework})` : ''),
natural ? '' : `\n$(warning) The solution does not build \`${project.info.name}\` under this configuration, ` +
`or maps it to a platform the project does not declare (${project.info.platforms.join(', ') || 'none'}).`,
'',
`Build task: \`${taskLabel('build')}\``,
'',
'Click to change the configuration.',
].join(' \n'));
this.configurationItem.tooltip.supportThemeIcons = true;
this.debugItem.tooltip = `Build and debug ${project.info.name} (${shown})`;
this.projectItem.show();
this.configurationItem.show();
this.debugItem.show();
}
dispose(): void {
this.projectItem.dispose();
this.configurationItem.dispose();
this.debugItem.dispose();
}
}
+118
View File
@@ -0,0 +1,118 @@
import * as vscode from 'vscode';
import { dotnetPath } from './msbuild';
import { ActiveTarget, SolutionModel } from './model';
export const TASK_TYPE = 'dotnet-solution';
export type BuildTarget = 'build' | 'rebuild' | 'clean';
export type BuildScope = 'solution' | 'project';
export interface SolutionTaskDefinition extends vscode.TaskDefinition {
type: typeof TASK_TYPE;
target?: BuildTarget;
scope?: BuildScope;
args?: string[];
}
/** The task label VS Code shows and `preLaunchTask` refers to: `dotnet-solution: Build`. */
export function taskLabel(target: BuildTarget): string {
return `${TASK_TYPE}: ${target[0].toUpperCase()}${target.slice(1)}`;
}
function configuredScope(): BuildScope {
return vscode.workspace.getConfiguration('dotnetSolution').get<BuildScope>('buildScope', 'solution');
}
/**
* The dotnet command for one target under the active selection.
*
* A solution build gets the *solution* configuration and platform (`Any CPU`, with the
* space); MSBuild then maps each project through the .sln, so MoonWorks builds as
* `Debug|Any CPU` while the MyGame projects build as `Debug|x64`. A project build gets
* the mapped project configuration directly.
*/
export function buildArguments(active: ActiveTarget, target: BuildTarget, scope: BuildScope, extra: string[] = []): string[] {
const args: string[] = [target === 'clean' ? 'clean' : 'build'];
if (scope === 'solution') {
args.push(active.solution.fsPath,
'-c', active.solutionConfiguration.configuration,
`-p:Platform=${active.solutionPlatform}`);
} else {
args.push(active.project.info.fsPath,
'-c', active.projectConfiguration,
`-p:Platform=${active.projectPlatform}`);
if (active.targetFramework) {
args.push(`-p:TargetFramework=${active.targetFramework}`);
}
}
if (target === 'rebuild') {
args.push('--no-incremental');
}
const settings = vscode.workspace.getConfiguration('dotnetSolution').get<string[]>('additionalBuildArguments', []);
args.push(...settings, ...extra);
return args;
}
export function createTask(model: SolutionModel, definition: SolutionTaskDefinition): vscode.Task | undefined {
const active = model.active;
if (!active) {
return undefined;
}
const target = definition.target ?? 'build';
const scope = definition.scope ?? configuredScope();
const args = buildArguments(active, target, scope, definition.args ?? []);
const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(active.solution.fsPath))
?? vscode.workspace.workspaceFolders?.[0];
const task = new vscode.Task(
{ ...definition, type: TASK_TYPE, target },
folder ?? vscode.TaskScope.Workspace,
`${target[0].toUpperCase()}${target.slice(1)}`,
TASK_TYPE,
new vscode.ProcessExecution(dotnetPath(), args, {
cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath,
env: vscode.workspace.getConfiguration('dotrush.msbuild').get<Record<string, string>>('additionalEnvironment'),
}),
'$msCompile');
task.group = target === 'clean' ? vscode.TaskGroup.Clean
: target === 'rebuild' ? vscode.TaskGroup.Rebuild
: vscode.TaskGroup.Build;
const what = scope === 'solution'
? `${active.solution.name}.sln ${active.solutionConfiguration.configuration}|${active.solutionPlatform}`
: `${active.project.info.name} ${active.projectConfiguration}|${active.projectPlatform}`;
// The detail line is what the task picker shows; say what is built, then how.
task.detail = `${what} — dotnet ${args.join(' ')}`;
task.presentationOptions = { reveal: vscode.TaskRevealKind.Silent, clear: true, showReuseMessage: false };
return task;
}
export class SolutionTaskProvider implements vscode.TaskProvider {
constructor(private readonly model: SolutionModel) { }
provideTasks(): vscode.Task[] {
const targets: BuildTarget[] = ['build', 'rebuild', 'clean'];
return targets
.map(target => createTask(this.model, { type: TASK_TYPE, target }))
.filter((task): task is vscode.Task => task !== undefined);
}
resolveTask(task: vscode.Task): vscode.Task | undefined {
const definition = task.definition as SolutionTaskDefinition;
if (definition.type !== TASK_TYPE) {
return undefined;
}
return createTask(this.model, definition);
}
}
/** Runs a task and resolves with its exit code, or -1 when it never produced one. */
export async function runTask(task: vscode.Task): Promise<number> {
const execution = await vscode.tasks.executeTask(task);
return new Promise(resolve => {
const listener = vscode.tasks.onDidEndTaskProcess(event => {
if (event.execution === execution) {
listener.dispose();
resolve(event.exitCode ?? -1);
}
});
});
}
+37
View File
@@ -0,0 +1,37 @@
import * as path from 'path';
import * as fs from 'fs';
import { runTests } from '@vscode/test-electron';
/**
* Launches VS Code on a real solution folder and runs the integration suite.
*
* Other extensions are disabled: DotRush is not needed to select a project and build a
* launch configuration, and without it the run cannot be influenced by its own state.
*/
async function main(): Promise<void> {
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
const extensionTestsPath = path.resolve(__dirname, './suite/index');
const folder = path.normalize(process.env.SOLUTION_TEST_FOLDER ?? 'D:/Projects/MyGame');
if (!fs.existsSync(folder)) {
throw new Error(`Test folder does not exist: ${folder}`);
}
console.log(`[runTest] folder: ${folder}`);
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [
folder,
'--disable-extensions',
'--disable-workspace-trust',
'--skip-welcome',
'--skip-release-notes',
],
});
}
main().catch(err => {
console.error('Integration tests failed:', err);
process.exit(1);
});
+21
View File
@@ -0,0 +1,21 @@
import * as path from 'path';
import Mocha = require('mocha');
export function run(): Promise<void> {
const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 120 * 1000, slow: 10 * 1000 });
mocha.addFile(path.resolve(__dirname, 'launcher.test.js'));
return new Promise((resolve, reject) => {
try {
mocha.run((failures: number) => {
if (failures > 0) {
reject(new Error(`${failures} test(s) failed.`));
} else {
resolve();
}
});
} catch (err) {
reject(err);
}
});
}
+68
View File
@@ -0,0 +1,68 @@
import * as assert from 'assert';
import * as path from 'path';
import * as vscode from 'vscode';
const EXTENSION_ID = 'local.dotnet-solution-launcher';
async function command<T>(id: string): Promise<T> {
return await vscode.commands.executeCommand<T>(id) as T;
}
async function waitFor(predicate: () => Promise<boolean>, ms = 30_000): Promise<void> {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise(resolve => setTimeout(resolve, 250));
}
throw new Error('timed out');
}
suite('.NET Solution Launcher on MyGame', () => {
suiteSetup(async () => {
const extension = vscode.extensions.getExtension(EXTENSION_ID);
assert.ok(extension, `${EXTENSION_ID} is not installed in the test host`);
await extension.activate();
await waitFor(async () => (await command<string | undefined>('dotnetSolution.activeSolutionPath')) !== undefined);
});
test('picks Nerfed.sln over lib/MoonWorks/MoonWorks.sln', async () => {
const solution = await command<string>('dotnetSolution.activeSolutionPath');
assert.strictEqual(path.basename(solution), 'Nerfed.sln');
});
test('startup project is an executable of the solution', async () => {
const name = await command<string>('dotnetSolution.activeProjectName');
assert.ok(['Nerfed.Builder', 'Nerfed.Editor'].includes(name), `unexpected startup project ${name}`);
});
test('default configuration is Debug on a platform the project declares', async () => {
// The suite may run after a user picked something else; only check the shape then.
const configuration = await command<string>('dotnetSolution.activeConfiguration');
const platform = await command<string>('dotnetSolution.activePlatform');
assert.ok(['Debug', 'Test', 'Release'].includes(configuration));
assert.ok(['x64', 'Any CPU', 'x86'].includes(platform));
const projectPlatform = await command<string>('dotnetSolution.activeProjectPlatform');
assert.ok(!projectPlatform.includes(' '), 'project platform uses MSBuild spelling');
});
test('program and target dir come from MSBuild with the platform applied', async () => {
const program = await command<string>('dotnetSolution.activeProgram');
const dir = await command<string>('dotnetSolution.activeTargetDir');
const name = await command<string>('dotnetSolution.activeProjectName');
assert.strictEqual(path.basename(program), `${name}.exe`);
assert.strictEqual(path.basename(dir), name);
assert.strictEqual(path.basename(path.dirname(dir)), 'Bin');
});
test('build tasks are provided with configuration and platform', async () => {
const tasks = await vscode.tasks.fetchTasks({ type: 'dotnet-solution' });
const build = tasks.find(task => task.definition.target === 'build');
assert.ok(build, 'no build task');
const execution = build.execution as vscode.ProcessExecution;
assert.ok(execution.args.some(arg => arg.startsWith('-p:Platform=')), execution.args.join(' '));
assert.ok(execution.args.includes('-c'));
assert.ok(execution.args.some(arg => arg.endsWith('.sln')), 'default scope builds the solution');
});
});
+87
View File
@@ -0,0 +1,87 @@
import * as assert from 'assert';
import * as fs from 'fs';
import * as path from 'path';
import { parseSln, parseSlnx, projectConfigurationFor, msbuildPlatform } from '../../sln';
import { parseProject } from '../../csproj';
const nerfed = process.env.SOLUTION_TEST_FOLDER ?? 'D:/Projects/MyGame';
suite('sln parser', () => {
const slnPath = path.join(nerfed, 'Nerfed.sln');
const available = fs.existsSync(slnPath);
test('reads projects, configurations and mappings from Nerfed.sln', function () {
if (!available) { this.skip(); }
const solution = parseSln(fs.readFileSync(slnPath, 'utf8'), slnPath);
assert.deepStrictEqual(solution.projects.map(p => p.name).sort(),
['MoonWorks', 'Nerfed.Builder', 'Nerfed.Editor', 'Nerfed.Runtime']);
assert.strictEqual(solution.configurations.length, 9);
assert.ok(solution.configurations.some(c => c.configuration === 'Test' && c.platform === 'x64'));
const editor = solution.projects.find(p => p.name === 'Nerfed.Editor')!;
assert.strictEqual(path.basename(editor.fsPath), 'Nerfed.Editor.csproj');
assert.deepStrictEqual(editor.dependencies, ['{1B88DE56-2AD8-441E-9B10-073AA43840BF}']);
assert.deepStrictEqual(projectConfigurationFor(editor, { configuration: 'Test', platform: 'x64' }),
{ configuration: 'Test', platform: 'x64', build: true });
// MoonWorks has no Test configuration: the solution maps Test|x64 to Debug|Any CPU.
const moonworks = solution.projects.find(p => p.name === 'MoonWorks')!;
assert.deepStrictEqual(projectConfigurationFor(moonworks, { configuration: 'Test', platform: 'x64' }),
{ configuration: 'Debug', platform: 'Any CPU', build: true });
});
test('skips solution folders', () => {
const text = [
'Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "lib", "lib", "{AAAA}"',
'EndProject',
'Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "App\App.csproj", "{BBBB}"',
'EndProject',
].join('\n');
const solution = parseSln(text, 'C:/x/s.sln');
assert.deepStrictEqual(solution.projects.map(p => p.name), ['App']);
});
test('parses slnx', () => {
const text = `<Solution>
<Configurations><BuildType Name="Debug" /><BuildType Name="Release" /><Platform Name="x64" /></Configurations>
<Project Path="App/App.csproj" />
<Folder Name="/lib/"><Project Path="lib/Lib.csproj" /></Folder>
</Solution>`;
const solution = parseSlnx(text, 'C:/x/s.slnx');
assert.deepStrictEqual(solution.projects.map(p => p.name), ['App', 'Lib']);
assert.deepStrictEqual(solution.configurations, [
{ configuration: 'Debug', platform: 'x64' }, { configuration: 'Release', platform: 'x64' }]);
});
test('msbuild platform spelling', () => {
assert.strictEqual(msbuildPlatform('Any CPU'), 'AnyCPU');
assert.strictEqual(msbuildPlatform('x64'), 'x64');
});
});
suite('csproj parser', () => {
test('reads Nerfed.Runtime as a library with Debug;Test;Release on x64', function () {
const file = path.join(nerfed, 'Nerfed.Runtime', 'Nerfed.Runtime.csproj');
if (!fs.existsSync(file)) { this.skip(); }
const info = parseProject(fs.readFileSync(file, 'utf8'), file);
assert.strictEqual(info.executable, false);
assert.deepStrictEqual(info.configurations, ['Debug', 'Test', 'Release']);
assert.deepStrictEqual(info.platforms, ['x64']);
assert.deepStrictEqual(info.targetFrameworks, ['net10.0']);
});
test('reads Nerfed.Editor as an executable', function () {
const file = path.join(nerfed, 'Nerfed.Editor', 'Nerfed.Editor.csproj');
if (!fs.existsSync(file)) { this.skip(); }
const info = parseProject(fs.readFileSync(file, 'utf8'), file);
assert.strictEqual(info.executable, true);
assert.strictEqual(info.assemblyName, 'Nerfed.Editor');
});
test('multi-targeting', () => {
const info = parseProject('<Project><PropertyGroup><OutputType>WinExe</OutputType><TargetFrameworks>net8.0;net10.0</TargetFrameworks></PropertyGroup></Project>', 'C:/a/B.csproj');
assert.strictEqual(info.multiTargeting, true);
assert.deepStrictEqual(info.targetFrameworks, ['net8.0', 'net10.0']);
assert.strictEqual(info.executable, true);
});
});