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
+4
View File
@@ -0,0 +1,4 @@
node_modules
out
*.vsix
.vscode-test
+13
View File
@@ -0,0 +1,13 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"preLaunchTask": "npm: compile"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "compile",
"problemMatcher": "$tsc",
"group": { "kind": "build", "isDefault": true }
}
]
}
+8
View File
@@ -0,0 +1,8 @@
.vscode/**
.vscode-test/**
src/**
out/test/**
tsconfig.json
**/*.map
.gitignore
package-lock.json
+15
View File
@@ -0,0 +1,15 @@
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+141
View File
@@ -0,0 +1,141 @@
# .NET Solution Launcher
Startup project and solution configuration in the status bar, with build and debug that
actually use them. Built for [DotRush](https://github.com/JaneySprings/DotRush), next to
the `.NET Hot Reload` extension in this repo.
```
$(project) MyGame.Editor $(settings-gear) Test | x64 $(debug-alt)
```
- The **project item** shows which `.csproj` is the startup project, and picks another one
from the executables in the solution (Runtime is a library, so it is not offered).
- The **configuration item** shows the *solution* configuration — `Debug | x64`,
`Test | x64`, `Release | Any CPU` — exactly as `MyGame.sln` lists them.
- The **debug button** builds with that configuration and launches the startup project.
## Why, when DotRush already has a status bar item
DotRush shows `Debug | net10.0`. Two things are missing from that:
1. **The project name.** It is in the tooltip only. With three executables in the
solution (Builder, Editor, and whatever comes next) that is the thing you need to see.
2. **The platform.** DotRush's build task runs `dotnet build <project> -p:Configuration=X`
with no `-p:Platform`. MSBuild then defaults to `AnyCPU`, and every property group
conditioned on `'$(Configuration)|$(Platform)' == 'Debug|x64'` is skipped. In this
solution that is not cosmetic:
| `MyGame.Runtime`, Configuration=Debug | `DefineConstants` |
| --- | --- |
| without `-p:Platform` (what DotRush runs) | `TRACE;DEBUG` |
| with `-p:Platform=x64` | `TRACE;LOG_INFO;PROFILING;DEBUG` |
So logging and profiling silently vanish, `Optimize` is never set for Test/Release,
and `MyGame.Builder` loses `AllowUnsafeBlocks` and fails to compile.
DotRush also cannot be *told* a configuration from outside — its selection lives in its
own workspace state — so this extension owns the build rather than wrapping
`dotrush: Build`. The startup *project* is synced with DotRush in both directions
(`dotnetSolution.syncDotRush`), so its test explorer, `launchSettings.json` lookup and
`${command:dotrush.activeProjectPath}` all agree with what the status bar says.
## What a build does
`dotnetSolution.buildScope` decides (default `solution`):
| Scope | Command | Notes |
| --- | --- | --- |
| `solution` | `dotnet build MyGame.sln -c Test -p:Platform=x64` | Like Visual Studio's *Build Solution*. MSBuild maps every project through the `.sln`: MyGame.* build as `Test\|x64`, MoonWorks as `Debug\|Any CPU`, and the Editor → Builder `ProjectDependencies` entry is honoured, so the Builder exists before the Editor's post-build step needs it. |
| `project` | `dotnet build MyGame.Editor.csproj -c Test -p:Platform=x64` | Only the startup project and its `ProjectReference`s. Faster, but solution-only dependencies are ignored. |
Rebuild adds `--no-incremental`; Clean runs `dotnet clean`. All three are tasks of type
`dotnet-solution`, so they show up under *Run Task* and can be used as `preLaunchTask`.
## What a launch does
`program` comes from MSBuild, not from guessing: the extension evaluates the startup
project with the mapped configuration and platform (`dotnet msbuild -getProperty:TargetPath …`),
which is the only way to get it right here — `OutDir` comes from `Directory.Build.props`
and `AppendTargetFrameworkToOutputPath` is off. The result is cached until a project or
solution file changes.
The apphost `.exe` is launched when the project produces one, since vsdbg wants an
executable; otherwise it runs `dotnet <TargetPath>`. Working directory is `TargetDir`
unless `dotnetSolution.launch.cwd` says otherwise. DotRush's debug configuration provider
still runs after this one and fills in `justMyCode`, symbol options and the console.
## Usage
| Action | How |
| --- | --- |
| Pick the startup project | Click the project name, or right-click a `.csproj`*Set as Startup Project (.NET Solution)* |
| Pick the configuration | Click `Debug \| x64`. Entries the solution does not build the project under are marked with `$(warning)` |
| Build / Rebuild / Clean | *.NET Solution: Build* etc., or the `dotnet-solution` tasks |
| Debug | The `$(debug-alt)` button, *.NET Solution: Debug Startup Project*, or F5 with the launch.json entry below |
| Run without debugging | *.NET Solution: Run Startup Project (without debugging)* |
| Several `.sln` files | *.NET Solution: Select Solution*, or `dotnetSolution.solution` |
### F5
The first time it sees a solution the extension offers to write a `launch.json` entry
(*.NET Solution: Create launch.json and tasks.json entries* does it later):
```jsonc
{
"name": ".NET Solution: Debug startup project",
"type": "coreclr",
"request": "launch",
"program": "${command:dotnetSolution.activeProgram}",
"cwd": "${command:dotnetSolution.activeTargetDir}",
"preLaunchTask": "dotnet-solution: Build"
}
```
The same entry is offered dynamically in the *Run and Debug* dropdown even without a
`launch.json`. It replaces the DotRush template's `${command:dotrush.activeTargetPath}` +
`preLaunchTask: "dotrush: Build"` pair, which is the one that builds without a platform.
### Variables for your own launch.json / tasks.json
| `${command:…}` | Value |
| --- | --- |
| `dotnetSolution.activeProgram` | apphost `.exe`, or the `.dll` when there is none |
| `dotnetSolution.activeTargetPath` | the built assembly |
| `dotnetSolution.activeTargetDir` | its directory |
| `dotnetSolution.activeProjectPath`, `activeProjectName` | the startup project |
| `dotnetSolution.activeSolutionPath` | the solution |
| `dotnetSolution.activeConfiguration`, `activePlatform` | solution configuration, e.g. `Test`, `Any CPU` |
| `dotnetSolution.activeProjectConfiguration`, `activeProjectPlatform` | what the project gets, e.g. `Test`, `AnyCPU` |
## Settings
| Setting | Default | |
| --- | --- | --- |
| `dotnetSolution.solution` | `""` | Solution to use; empty picks the shallowest, then the largest |
| `dotnetSolution.buildScope` | `solution` | See above |
| `dotnetSolution.targetFramework` | `""` | For `TargetFrameworks` projects; empty takes the first |
| `dotnetSolution.launch.args` / `.env` / `.cwd` / `.console` | | Passed to the launched application |
| `dotnetSolution.debugType` | `coreclr` | |
| `dotnetSolution.syncDotRush` | `true` | Push the startup project to DotRush and follow its changes |
| `dotnetSolution.syncDotRushWorkspaceProperties` | `false` | Write `Configuration=…;Platform=…` into `dotrush.roslyn.workspaceProperties` so IntelliSense sees the same `DefineConstants`. Off because DotRush reloads its workspace on every change |
| `dotnetSolution.additionalBuildArguments` | `[]` | Appended to every build |
`dotrush.roslyn.dotnetSdkDirectory` and `dotrush.msbuild.additionalEnvironment` are
honoured, so both extensions use the same SDK.
## Tips
- DotRush's own `Debug | net10.0` item can be hidden: right-click the status bar and
untick *DotRush*. Nothing else depends on it once this extension drives the build.
- The status bar goes to a spinner while a build runs; the build output is in the
terminal and the *Problems* panel (`$msCompile`).
- The *.NET Solution* output channel logs every `dotnet` invocation.
## Development
```
npm install
npm run compile
npm run test:unit # parser tests, against D:\Projects\MyGame when present
npm test # launches VS Code on that folder (SOLUTION_TEST_FOLDER overrides)
```
+1362
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
{
"name": "dotnet-solution-launcher",
"displayName": ".NET Solution Launcher",
"description": "Startup project and solution configuration (Debug|x64, Test|x64, ...) in the status bar, with build and debug that honour them. Made for DotRush, which shows the configuration but not the project and never passes the platform.",
"version": "0.1.0",
"publisher": "local",
"license": "MIT",
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Debuggers",
"Other"
],
"keywords": [
"dotnet",
"csharp",
"solution",
"configuration",
"dotrush",
"startup project"
],
"activationEvents": [
"onLanguage:csharp",
"onDebug",
"workspaceContains:**/*.{sln,slnx,csproj}"
],
"main": "./out/extension.js",
"contributes": {
"commands": [
{ "command": "dotnetSolution.selectStartupProject", "title": "Select Startup Project", "category": ".NET Solution" },
{ "command": "dotnetSolution.selectConfiguration", "title": "Select Configuration", "category": ".NET Solution" },
{ "command": "dotnetSolution.selectSolution", "title": "Select Solution", "category": ".NET Solution" },
{ "command": "dotnetSolution.build", "title": "Build", "category": ".NET Solution", "icon": "$(tools)" },
{ "command": "dotnetSolution.rebuild", "title": "Rebuild", "category": ".NET Solution" },
{ "command": "dotnetSolution.clean", "title": "Clean", "category": ".NET Solution" },
{ "command": "dotnetSolution.debug", "title": "Debug Startup Project", "category": ".NET Solution", "icon": "$(debug-alt)" },
{ "command": "dotnetSolution.run", "title": "Run Startup Project (without debugging)", "category": ".NET Solution", "icon": "$(play)" },
{ "command": "dotnetSolution.generateLaunchConfig", "title": "Create launch.json and tasks.json entries", "category": ".NET Solution" },
{ "command": "dotnetSolution.reload", "title": "Reload Solution", "category": ".NET Solution" },
{ "command": "dotnetSolution.showOutput", "title": "Show Log", "category": ".NET Solution" },
{ "command": "dotnetSolution.setStartupProject", "title": "Set as Startup Project (.NET Solution)", "category": ".NET Solution" }
],
"menus": {
"explorer/context": [
{
"command": "dotnetSolution.setStartupProject",
"when": "resourceExtname =~ /\\.(cs|fs|vb)proj$/",
"group": "dotnet_0@0"
}
],
"commandPalette": [
{ "command": "dotnetSolution.setStartupProject", "when": "false" }
]
},
"taskDefinitions": [
{
"type": "dotnet-solution",
"required": [],
"properties": {
"target": {
"type": "string",
"enum": ["build", "rebuild", "clean"],
"default": "build",
"description": "What to do. Defaults to build."
},
"scope": {
"type": "string",
"enum": ["solution", "project"],
"description": "Build the whole solution with the selected solution configuration, or only the startup project with the project configuration the solution maps it to. Defaults to the dotnetSolution.buildScope setting."
},
"args": {
"type": "array",
"items": { "type": "string" },
"description": "Extra arguments appended to the dotnet command."
}
}
}
],
"problemMatchers": [],
"configuration": {
"title": ".NET Solution Launcher",
"properties": {
"dotnetSolution.solution": {
"type": "string",
"default": "",
"description": "Solution file to use, relative to the workspace. Empty picks the one closest to the workspace root, preferring the one with the most projects."
},
"dotnetSolution.buildScope": {
"type": "string",
"enum": ["solution", "project"],
"enumDescriptions": [
"dotnet build <solution> -c <Configuration> -p:Platform=<Platform>. Honours solution-level project dependencies and per-project configuration mapping, like Visual Studio's Build Solution.",
"dotnet build <startup project> with the configuration and platform the solution maps it to. Faster, but ignores dependencies that only exist in the .sln (ProjectDependencies)."
],
"default": "solution",
"description": "What the build task builds before a launch."
},
"dotnetSolution.targetFramework": {
"type": "string",
"default": "",
"description": "Target framework to launch for multi-targeting projects (TargetFrameworks). Empty uses the first one."
},
"dotnetSolution.launch.args": {
"type": "array",
"items": { "type": "string" },
"default": [],
"description": "Arguments passed to the startup project when launched from this extension."
},
"dotnetSolution.launch.env": {
"type": "object",
"additionalProperties": { "type": "string" },
"default": {},
"description": "Environment variables for the launched startup project."
},
"dotnetSolution.launch.cwd": {
"type": "string",
"default": "",
"description": "Working directory for the launched startup project. Empty uses the output directory (TargetDir)."
},
"dotnetSolution.launch.console": {
"type": "string",
"enum": ["internalConsole", "integratedTerminal", "externalTerminal"],
"default": "internalConsole",
"description": "Console for the debugged application."
},
"dotnetSolution.debugType": {
"type": "string",
"default": "coreclr",
"description": "Debug adapter type. DotRush and C# Dev Kit both provide coreclr."
},
"dotnetSolution.syncDotRush": {
"type": "boolean",
"default": true,
"markdownDescription": "Keep DotRush's own startup project in sync (`dotrush.setStartupProject`), so its test explorer, launchSettings lookup and `${command:dotrush.activeProjectPath}` agree with the status bar here."
},
"dotnetSolution.syncDotRushWorkspaceProperties": {
"type": "boolean",
"default": false,
"markdownDescription": "Write `Configuration=<Cfg>;Platform=<Platform>` into `dotrush.roslyn.workspaceProperties` when the configuration changes, so IntelliSense sees the same DefineConstants as the build. DotRush reloads its workspace on each change, which is why this is off by default."
},
"dotnetSolution.additionalBuildArguments": {
"type": "array",
"items": { "type": "string" },
"default": [],
"description": "Extra arguments for every dotnet build / clean run by this extension."
}
}
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"pretest": "npm run compile",
"test": "node ./out/test/runTest.js",
"test:unit": "npm run compile && mocha --ui tdd out/test/unit/*.test.js"
},
"devDependencies": {
"@types/mocha": "^10.0.6",
"@types/node": "^20.11.0",
"@types/vscode": "^1.85.0",
"@vscode/test-electron": "^2.4.1",
"mocha": "^10.4.0",
"typescript": "^5.4.0"
}
}
+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);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"outDir": "out",
"lib": ["ES2022"],
"sourceMap": true,
"rootDir": "src",
"strict": true
},
"exclude": ["node_modules", ".vscode-test"]
}