Created
March 14, 2023 15:32
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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 - © 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