Skip to content

Instantly share code, notes, and snippets.

@mobzystems
Created March 14, 2023 15:32
Show Gist options
  • Save mobzystems/1652c51c334e9bd42a556ff466017636 to your computer and use it in GitHub Desktop.
Save mobzystems/1652c51c334e9bd42a556ff466017636 to your computer and use it in GitHub Desktop.
Get all project dependencies from a Visual Studio solution file and create an interactive HTML map
#Requires -Version 4
<#
This cmdlet reads a Visual Studio solution file and all projects in it
to determine the dependencies between them. The result is a collection
of Project instances (see class Project)
Each project has a name and (full) path, but also a list of dependencies
in .DependsOn and a list of referencing projects in .ReferencedBy.
These are hashtables of the actual projects, with their full path as the key.
#>
[Cmdletbinding()]
# [OutputType([Project[]])] -- can't do this
Param(
# The path to the solution file
[Parameter(Mandatory)]
[string]$Path
)
# Check the parameter(s)
if (-not (Test-Path $Path -PathType Leaf)) {
Write-Warning "The solution file '$Path' does not exist"
return
}
# Get the path of the solution file
$solutionDir = Split-Path -Path (Resolve-Path $Path)
# Read the contents of the solution file
$solutionLines = Get-Content $Path
# The projects are stored in objects of this class
class Project {
# The type of the project (a guid)
# See https://www.codeproject.com/Reference/720512/List-of-Visual-Studio-Project-Type-GUIDs
[string]$Type
# The (display) name of the project
[string]$Name
# The path of the project, relative to the solution
[string]$Path
# The project guid
[string]$Guid
# The full path of the project
[string]$FullPath
# A hashtable of projects (key: full path) this project depends on
[hashtable]$DependsOn
# A hashtable of projects (key: full path) this project is referenced by
[hashtable]$ReferencedBy
[string]ToString() {
return $this.Name
}
}
# A hashtable of projects (key: full path) in the solution
$projects = @{}
# Process the solution file by searching for lines like
# Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Loon.Authentication", "Loon.Authentication\Loon.Authentication.csproj", "{6F34700B-5155-4338-8D60-86EFF672A1A9}"
# Split each line into type/name/path/guid
$projectRegex = '^Project\(\"{(?<type>[A-Z0-9-]+)}\"\) = \"(?<name>.*?)\", \"(?<path>.*?)\", \"{(?<guid>[A-Z0-9-]+)}\"$'
foreach ($solutionLine in $solutionLines) {
if ($solutionLine -match $projectRegex) {
Write-Verbose "Found project $($Matches.type): $($Matches.name) in $($Matches.path) guid $($Matches.guid)"
# Skip Solution Items and Web sites
if ($Matches.type -ne '2150E333-8FDC-42A3-9474-1A3956D46DE8' -and $Matches.type -ne 'E24C65DC-7377-472B-9ABA-BC803B73C61A') {
# Create a new project in the projects hash table
$project = [Project]::new()
$project.Type = $Matches.type
$project.Name = $Matches.name
$project.Path = $Matches.path
$project.Guid = $Matches.guid
$project.FullPath = [System.IO.Path]::Combine($solutionDir, $project.Path)
$project.DependsOn = @{}
$project.ReferencedBy = @{}
$projects.Add($project.FullPath, $project)
}
}
}
# Next, parse each project file for ProjectReference nodes
foreach ($project in $projects.Values) {
Write-Verbose "Scanning project $($project.FullPath)..."
# Get the directory the project is in
$projectDir = (Split-Path -Path $project.FullPath)
# Get all ProjectReference nodes (and their Include attributes)
$references = ([xml](Get-Content $project.FullPath)).Project.ItemGroup.ProjectReference
# Handle all references (in fact: dependencies)
foreach ($reference in $references.Include) {
# Resolve the Include path to a full path relative to the project directory
$referencePath = (Resolve-Path -Path ([System.IO.Path]::Combine($projectDir, $reference))).Path
# Find the project in the existing list of projects
$refProject = $projects[$referencePath]
if (-not $refProject) {
# Not found - issue warning
Write-Warning "Project $referencePath is referenced by project $($project.FullPath) but is not part of the solution"
}
else {
# Found - add the referenced project to our dependencies
$project.DependsOn.Add($referencePath, $refProject)
# Also add ourselves to the "referencers" of the referenced project
if ($refProject -icontains $referencePath) {
# Already there (should never happen)
Write-Warning "Project $refProject is referenced by $($project.FullPath) more than once"
}
else {
$refProject.ReferencedBy.Add($project.FullPath, $project)
}
}
}
}
# Return the projects themselves, not the hashtable they're in
$projects.Values
[Cmdletbinding()]
Param(
# The path to the solution file
[Parameter(Mandatory)]
[string]$Path
)
$projects = &"$PSScriptRoot\Get-SolutionDependencies.ps1" -Path $Path
$levels = @{}
$currentLevel = 0
$handledProjectPaths = @{}
do {
# Handle all projects that have not been handled before (e.g. not added to $handledProjectPaths)
$levelProjects = $projects.Where({
$handledProjectPaths.Keys -notcontains $_.FullPath
}).Where({
# ... and they have no unhandled dependencies left
$_.DependsOn.Keys.Where({ $handledProjectPaths.Keys -notcontains $_ }).Count -eq 0
})
# No projects left? End loop
if (-not $levelProjects) {
break;
}
# Add all of the projects found to the handled projects
$levelProjects.ForEach({ $handledProjectPaths.Add($_.FullPath, $_) })
# Create a new level
$currentLevel++
# Add the projects to the level
$levels.Add($currentLevel, $levelProjects)
} while ($true)
# Create HTML for the levels and projects
$body = @"
$(for ($level = $currentLevel; $level -ge 1; $level--) {@"
<div class="level">
$($levels[$level].ForEach({
$project = $_
@"
<div class="project">
<h1 class="collapsible collapsed" data-ref="$($project.Name)">$($project.Name)</h1>
$(if ($project.DependsOn.Count -eq 0 -and $project.ReferencedBy.count -eq 0) {@"
<p>No dependencies or references.</p>
"@})
$(if ($project.DependsOn.Count -ne 0) {@"
<h2 class="deps collapsible collapsed">Depends on ($($project.DependsOn.Count))</h2>
<ol>
$($project.DependsOn.Values.ForEach({@"
<li data-ref="$($_.Name)">$($_.Name)</li>
"@}))
</ol>
"@})
$(if ($project.ReferencedBy.Count -ne 0) {@"
<h2 class="refs collapsible collapsed">Referenced by ($($project.ReferencedBy.Count))</h2>
<ol>
$($project.ReferencedBy.Values.ForEach({@"
<li data-ref="$($_.Name)">$($_.Name)</li>
"@}))
</ol>
"@})
</div>
"@}))
</div>
"@})
"@
# $body
$html = (Get-Content "$PSScriptRoot\template.html").Replace('#BODY#', $body)
$html
<!DOCTYPE html>
<head>
<style>
body {
font-family: "Segoe UI", Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
overflow: scroll;
}
.level {
display: flex;
justify-content: center;
}
.project {
flex: 0 auto;
/* border: solid 1px black; */
margin: 5px;
align-self: flex-start;
background-color: lightblue;
padding: 5px;
}
.project h1 {
font-size: 1.1rem;
padding: 10px;
text-align: center;
margin: 0;
border-bottom: solid 1px black;
margin: -5px;
}
.project h1.collapsible.collapsed {
border-bottom: none;
}
.project h2 {
font-size: 1rem;
margin: 10px 0;
}
.project h1.selected, .project li.selected {
background-color: lightseagreen;
}
.project ol {
list-style: none;
margin: 5px;
padding: 0;
}
.project ol li {
padding: 5px;
}
.collapsible {
white-space: nowrap;
}
.collapsible::before {
content: "▼︎ ";
font-size: .75em;
width: 1.1em;
display: inline-block;
}
.collapsible.collapsed::before {
content: "▶︎ ";
}
.collapsible.collapsed+ol {
display: none;
}
.footer {
margin-top: 50px;
padding: 5px;
}
h1.collapsible.collapsed ~ * {
display: none;
}
#template {
position: fixed;
width: 10em;
top: 0;
right: 0;
}
#template .project * {
display: block !important;
}
#template .project {
border: solid 1px lightblue;
}
</style>
</head>
<body>
<div id="template">
<div class="project">
<h1 id="allprojects" class="collapsible collapsed">Project</h1>
<h2 id="alldependencies" class="collapsible collapsed">Depends on</h2>
<h2 id="allreferences" class="collapsible collapsed">Referenced by</h2>
</div>
</div>#BODY#
<div class="footer">Dependency Graph - &copy; 2023 <a href="https://www.mobzystems.com/" target="blank">MOBZystems -
Home of Tools</a></div>
<script>
document.addEventListener('mouseover', function (e) {
if (e.target.dataset.ref) {
const ref = e.target.dataset.ref;
document.querySelectorAll(`[data-ref="${ref}"]`).forEach(e => e.classList.add('selected'));
}
});
document.addEventListener('mouseout', function (e) {
if (e.target.dataset.ref) {
document.querySelectorAll(`[data-ref]`).forEach(e => e.classList.remove('selected'));
}
});
document.addEventListener('click', function (e) {
const isCollapsed = e.target.classList.contains('collapsed');
function setCollapsed(e, coll) {
if (coll) e.classList.add('collapsed');
else e.classList.remove('collapsed');
}
if (e.target.classList.contains('collapsible'))
e.target.classList.toggle('collapsed');
if (e.target.id === 'allprojects')
document.querySelectorAll(".level .project h1").forEach(e => setCollapsed(e, !isCollapsed));
if (e.target.id === 'alldependencies')
document.querySelectorAll(".level .project h2.deps").forEach(e => setCollapsed(e, !isCollapsed));
if (e.target.id === 'allreferences')
document.querySelectorAll(".level .project h2.refs").forEach(e => setCollapsed(e, !isCollapsed));
})
</script>
</body>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment