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:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
out
|
||||||
|
*.vsix
|
||||||
|
.vscode-test
|
||||||
Vendored
+13
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "compile",
|
||||||
|
"problemMatcher": "$tsc",
|
||||||
|
"group": { "kind": "build", "isDefault": true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.vscode/**
|
||||||
|
.vscode-test/**
|
||||||
|
src/**
|
||||||
|
out/test/**
|
||||||
|
tsconfig.json
|
||||||
|
**/*.map
|
||||||
|
.gitignore
|
||||||
|
package-lock.json
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
Generated
+1362
File diff suppressed because it is too large
Load Diff
+167
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "ES2022",
|
||||||
|
"outDir": "out",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"sourceMap": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", ".vscode-test"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user