Skip to content

Commit 56e811c

Browse files
committed
Fix VS Code AppHost launch path resolution
1 parent 5066d54 commit 56e811c

5 files changed

Lines changed: 263 additions & 1 deletion

File tree

extension/src/debugger/AspireDebugConfigurationProvider.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as vscode from 'vscode';
22
import { defaultConfigurationName } from '../loc/strings';
3+
import { resolveAppHostLaunchPath } from '../utils/appHostLaunchPath';
34
import { checkCliAvailableOrRedirect } from '../utils/workspace';
45

56
export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider {
@@ -44,4 +45,12 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati
4445

4546
return config;
4647
}
48+
49+
async resolveDebugConfigurationWithSubstitutedVariables(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration | null | undefined> {
50+
if (typeof config.program === 'string') {
51+
config.program = await resolveAppHostLaunchPath(config.program);
52+
}
53+
54+
return config;
55+
}
4756
}

extension/src/editor/AspireEditorCommandProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { noAppHostInWorkspace } from '../loc/strings';
44
import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions';
55
import { AspireCommandType } from '../dcp/types';
66
import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from '../utils/cliTypes';
7+
import { resolveAppHostLaunchPath } from '../utils/appHostLaunchPath';
78

89
export class AspireEditorCommandProvider implements vscode.Disposable {
910
private _workspaceAppHostPath: string | null = null;
@@ -204,7 +205,7 @@ export class AspireEditorCommandProvider implements vscode.Disposable {
204205
*/
205206
public async getAppHostPath(): Promise<string | null> {
206207
if (vscode.window.activeTextEditor && await this.isAppHostFile(vscode.window.activeTextEditor.document.uri.fsPath)) {
207-
return vscode.window.activeTextEditor.document.uri.fsPath;
208+
return await resolveAppHostLaunchPath(vscode.window.activeTextEditor.document.uri.fsPath);
208209
}
209210

210211
return this._workspaceAppHostPath;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/// <reference types="mocha" />
2+
3+
import * as assert from 'assert';
4+
import * as fs from 'fs';
5+
import * as os from 'os';
6+
import * as path from 'path';
7+
import { AspireDebugConfigurationProvider } from '../debugger/AspireDebugConfigurationProvider';
8+
9+
suite('AspireDebugConfigurationProvider', () => {
10+
let tempDir: string;
11+
12+
setup(() => {
13+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-debug-configuration-provider-'));
14+
});
15+
16+
teardown(() => {
17+
fs.rmSync(tempDir, { recursive: true, force: true });
18+
});
19+
20+
test('resolves launch config SDK-style AppHost Program.cs to containing project file', async () => {
21+
const appHostDirectory = path.join(tempDir, 'AppHost');
22+
fs.mkdirSync(appHostDirectory);
23+
24+
const programPath = path.join(appHostDirectory, 'Program.cs');
25+
const projectPath = path.join(appHostDirectory, 'AppHost.csproj');
26+
fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();');
27+
fs.writeFileSync(projectPath, '<Project Sdk="Microsoft.NET.Sdk" />');
28+
29+
const provider = new AspireDebugConfigurationProvider();
30+
const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, {
31+
name: 'Debug AppHost',
32+
type: 'aspire',
33+
request: 'launch',
34+
program: programPath
35+
});
36+
37+
assert.strictEqual(config?.program, projectPath);
38+
});
39+
40+
test('leaves launch config single-file apphost.cs unchanged', async () => {
41+
const appHostPath = path.join(tempDir, 'apphost.cs');
42+
fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);');
43+
44+
const provider = new AspireDebugConfigurationProvider();
45+
const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, {
46+
name: 'Debug AppHost',
47+
type: 'aspire',
48+
request: 'launch',
49+
program: appHostPath
50+
});
51+
52+
assert.strictEqual(config?.program, appHostPath);
53+
});
54+
55+
test('leaves launch config non-AppHost C# source file unchanged', async () => {
56+
const appDirectory = path.join(tempDir, 'App');
57+
fs.mkdirSync(appDirectory);
58+
59+
const programPath = path.join(appDirectory, 'Program.cs');
60+
fs.writeFileSync(programPath, 'Console.WriteLine("Hello");');
61+
fs.writeFileSync(path.join(appDirectory, 'App.csproj'), '<Project Sdk="Microsoft.NET.Sdk" />');
62+
63+
const provider = new AspireDebugConfigurationProvider();
64+
const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, {
65+
name: 'Debug AppHost',
66+
type: 'aspire',
67+
request: 'launch',
68+
program: programPath
69+
});
70+
71+
assert.strictEqual(config?.program, programPath);
72+
});
73+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/// <reference types="mocha" />
2+
3+
import * as assert from 'assert';
4+
import * as fs from 'fs';
5+
import * as os from 'os';
6+
import * as path from 'path';
7+
import * as sinon from 'sinon';
8+
import * as vscode from 'vscode';
9+
import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider';
10+
11+
function createEditor(filePath: string): vscode.TextEditor {
12+
return {
13+
document: {
14+
uri: vscode.Uri.file(filePath),
15+
fileName: filePath,
16+
languageId: 'csharp'
17+
} as vscode.TextDocument
18+
} as vscode.TextEditor;
19+
}
20+
21+
suite('AspireEditorCommandProvider', () => {
22+
let tempDir: string;
23+
let activeEditor: vscode.TextEditor | undefined;
24+
let activeEditorStub: sinon.SinonStub;
25+
let workspaceFoldersStub: sinon.SinonStub;
26+
let getWorkspaceFolderStub: sinon.SinonStub;
27+
let onDidChangeWorkspaceFoldersStub: sinon.SinonStub;
28+
let onDidChangeActiveTextEditorStub: sinon.SinonStub;
29+
let executeCommandStub: sinon.SinonStub;
30+
31+
setup(() => {
32+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-editor-command-provider-'));
33+
activeEditor = undefined;
34+
35+
activeEditorStub = sinon.stub(vscode.window, 'activeTextEditor').get(() => activeEditor);
36+
workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined);
37+
getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => {
38+
if (uri.fsPath.startsWith(tempDir)) {
39+
return { uri: vscode.Uri.file(tempDir), name: 'test', index: 0 };
40+
}
41+
42+
return undefined;
43+
});
44+
onDidChangeWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'onDidChangeWorkspaceFolders').returns({ dispose: () => { } } as vscode.Disposable);
45+
onDidChangeActiveTextEditorStub = sinon.stub(vscode.window, 'onDidChangeActiveTextEditor').returns({ dispose: () => { } } as vscode.Disposable);
46+
executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined);
47+
});
48+
49+
teardown(() => {
50+
executeCommandStub.restore();
51+
onDidChangeActiveTextEditorStub.restore();
52+
onDidChangeWorkspaceFoldersStub.restore();
53+
getWorkspaceFolderStub.restore();
54+
workspaceFoldersStub.restore();
55+
activeEditorStub.restore();
56+
fs.rmSync(tempDir, { recursive: true, force: true });
57+
});
58+
59+
test('returns containing project file when active editor is SDK-style AppHost Program.cs', async () => {
60+
const appHostDirectory = path.join(tempDir, 'AppHost');
61+
fs.mkdirSync(appHostDirectory);
62+
63+
const programPath = path.join(appHostDirectory, 'Program.cs');
64+
const projectPath = path.join(appHostDirectory, 'AppHost.csproj');
65+
fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();');
66+
fs.writeFileSync(projectPath, '<Project Sdk="Microsoft.NET.Sdk" />');
67+
activeEditor = createEditor(programPath);
68+
69+
const provider = new AspireEditorCommandProvider();
70+
try {
71+
assert.strictEqual(await provider.getAppHostPath(), projectPath);
72+
}
73+
finally {
74+
provider.dispose();
75+
}
76+
});
77+
78+
test('returns source file when active editor is single-file apphost.cs', async () => {
79+
const appHostPath = path.join(tempDir, 'apphost.cs');
80+
fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);');
81+
activeEditor = createEditor(appHostPath);
82+
83+
const provider = new AspireEditorCommandProvider();
84+
try {
85+
assert.strictEqual(await provider.getAppHostPath(), appHostPath);
86+
}
87+
finally {
88+
provider.dispose();
89+
}
90+
});
91+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { Dirent } from 'fs';
2+
import * as fs from 'fs/promises';
3+
import * as path from 'path';
4+
import * as vscode from 'vscode';
5+
6+
export async function resolveAppHostLaunchPath(filePath: string): Promise<string> {
7+
if (path.extname(filePath).toLowerCase() !== '.cs') {
8+
return filePath;
9+
}
10+
11+
let fileText: string;
12+
try {
13+
fileText = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)).then(buffer => buffer.toString());
14+
}
15+
catch {
16+
return filePath;
17+
}
18+
19+
const lines = fileText.split(/\r?\n/);
20+
21+
// Single-file C# AppHosts are launched directly from source and start with:
22+
// #:sdk Aspire.AppHost.Sdk
23+
// The CLI accepts this source file shape, so do not rewrite it to a project path.
24+
if (lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk'))) {
25+
return filePath;
26+
}
27+
28+
if (!lines.some(line => line.includes('DistributedApplication.CreateBuilder'))) {
29+
return filePath;
30+
}
31+
32+
// SDK-style C# AppHosts usually launch from Program.cs:
33+
// var builder = DistributedApplication.CreateBuilder(args);
34+
// The CLI needs the containing .csproj instead of Program.cs so the AppHost SDK
35+
// and project references load.
36+
return await tryFindContainingProjectFile(filePath) ?? filePath;
37+
}
38+
39+
async function tryFindContainingProjectFile(filePath: string): Promise<string | null> {
40+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath));
41+
const workspaceRoot = workspaceFolder?.uri.fsPath;
42+
let directory = path.dirname(filePath);
43+
44+
while (true) {
45+
const projectFile = await tryGetProjectFileInDirectory(directory);
46+
if (projectFile !== undefined) {
47+
return projectFile;
48+
}
49+
50+
if (workspaceRoot && path.resolve(directory) === path.resolve(workspaceRoot)) {
51+
return null;
52+
}
53+
54+
const parent = path.dirname(directory);
55+
if (parent === directory) {
56+
return null;
57+
}
58+
59+
directory = parent;
60+
}
61+
}
62+
63+
async function tryGetProjectFileInDirectory(directory: string): Promise<string | null | undefined> {
64+
let entries: Dirent[];
65+
try {
66+
entries = await fs.readdir(directory, { withFileTypes: true });
67+
}
68+
catch {
69+
return undefined;
70+
}
71+
72+
const projectFiles = entries
73+
.filter(entry => entry.isFile() && /\.(csproj|fsproj|vbproj)$/i.test(entry.name))
74+
.map(entry => entry.name);
75+
76+
if (projectFiles.length === 0) {
77+
return undefined;
78+
}
79+
80+
if (projectFiles.length === 1) {
81+
return path.join(directory, projectFiles[0]);
82+
}
83+
84+
const directoryName = path.basename(directory);
85+
const matchingProjectFile = projectFiles.find(projectFile =>
86+
path.basename(projectFile, path.extname(projectFile)).toLowerCase() === directoryName.toLowerCase());
87+
return matchingProjectFile ? path.join(directory, matchingProjectFile) : null;
88+
}

0 commit comments

Comments
 (0)