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(); 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>(); 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('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 { 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 { await this.state.update(STATE_SOLUTION, solution.fsPath); await this.reload(); } async selectProject(project: StartupProject): Promise { 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 { 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 { 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 { 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('solution', ''); const wanted = configured ? resolveWorkspacePath(configured) : this.state.get(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(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(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 { 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 { 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)); }