NetscootShared/Dotnet/Solutions.ps1
|
# Hoisted once: these run per line of every .sln (and per project entry) during inventory, # consistency, and membership scans, so they are built here rather than per call. $script:SlnProjectEntryRegex = [regex]'^\s*Project\("\{[^}]+\}"\)\s*=\s*"[^"]*",\s*"([^"]+)",\s*"\{[^}]+\}"' $script:SlnProjectFullRegex = [regex]'^\s*Project\("\{([^}]+)\}"\)\s*=\s*"([^"]*)",\s*"([^"]+)",\s*"\{([^}]+)\}"' # Project-file extensions that count as a "project" for membership comparison and sync. Includes # managed (cs/fs/vb), native (vcx), and PowerShell (pss). Get-SolutionInventory shows pssproj rows; # Test-SolutionConsistency / Sync-Solution must compare them too, otherwise the inventory and the # consistency check disagree (a pssproj in slnx but not sln reads as "all solutions agree"). $script:ProjectFileExtRegex = [regex]'\.(cs|fs|vb|vcx|pss)proj$' function Find-Solutions { # All .sln and .slnx files beneath a root. # Filter by extension via Where-Object, not Get-ChildItem -Include: on Windows # PowerShell 5.1, -Include is ignored when combined with -LiteralPath (returns # every file). Where-Object behaves identically on both editions. [CmdletBinding()] param([Parameter(Mandatory)][string]$Root) $nested = Get-NestedWorktreePath -Root $Root # linked worktrees hold duplicate copies Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.sln', '.slnx' -and $_.FullName -notmatch '[\\/](bin|obj|\.vs|\.git)[\\/]' -and -not (Test-PathUnderAny -Path $_.FullName -Dirs $nested) } } function Read-Solution { # Parse one solution file (.sln or .slnx) exactly once into a Netscoot.Solution domain object # that carries everything the readers need, so inventory, consistency, membership, and rebase # all derive from a single read instead of re-parsing the file (or shelling `dotnet sln list`) # per call. # # The object's shape (pscustomobject, PSTypeName='Netscoot.Solution'): # Path - absolute path to the solution file # Format - 'sln' or 'slnx' # Projects - every project entry (any type, incl. .pssproj/.vcxproj that `dotnet sln list` # may omit). Each: @{ Stored; Abs; Ext; TypeGuid } # (TypeGuid is $null for .slnx, which records no project-type GUIDs). # Folders - solution-folder names (never reported as projects) # Items - solution items (loose files) [CmdletBinding()] param([Parameter(Mandatory)][string]$SolutionFile) $full = Resolve-FullPath $SolutionFile $dir = Split-Path -Parent $full $projects = @(); $folders = @(); $items = @() if ([System.IO.Path]::GetExtension($full) -ieq '.slnx') { $format = 'slnx' $xml = Read-ProjectXml -Path $full foreach ($n in $xml.SelectNodes('//*[local-name()="Project"]')) { $p = $n.GetAttribute('Path') if ([string]::IsNullOrWhiteSpace($p)) { continue } $abs = [System.IO.Path]::GetFullPath((Join-Path $dir ($p.Replace('/', '\')))) $projects += [pscustomobject]@{ Stored = $p; Abs = $abs; Ext = [System.IO.Path]::GetExtension($p); TypeGuid = $null } } foreach ($n in $xml.SelectNodes('//*[local-name()="Folder"]')) { $name = $n.GetAttribute('Name'); if ($name) { $folders += $name } } foreach ($n in $xml.SelectNodes('//*[local-name()="File"]')) { $p = $n.GetAttribute('Path'); if ($p) { $items += $p } } } else { $format = 'sln' $folderTypeGuid = '2150E333-8FDC-42A3-9474-1A3956D46DE8' # solution-folder project type $inItems = $false foreach ($line in (Get-Content -LiteralPath $full)) { $m = $script:SlnProjectFullRegex.Match($line) if ($m.Success) { $typeGuid = $m.Groups[1].Value; $name = $m.Groups[2].Value; $p = $m.Groups[3].Value if ($typeGuid -ieq $folderTypeGuid) { $folders += $name; continue } $abs = [System.IO.Path]::GetFullPath((Join-Path $dir $p)) $projects += [pscustomobject]@{ Stored = $p; Abs = $abs; Ext = [System.IO.Path]::GetExtension($p); TypeGuid = $typeGuid } } elseif ($line -match '^\s*ProjectSection\(SolutionItems\)') { $inItems = $true } elseif ($line -match '^\s*EndProjectSection') { $inItems = $false } elseif ($inItems -and $line -match '^\s*(.+?)\s*=\s*(.+?)\s*$') { $items += $Matches[1].Trim() } } } return [pscustomobject]@{ PSTypeName = 'Netscoot.Solution' Path = $full Format = $format Projects = $projects Folders = $folders Items = $items } } function Get-SolutionProjectEntries { # The project entries stored in a solution, as the exact string written in the file plus # its resolved absolute path. Used to rebase a solution's relative paths when it moves. # Skips solution folders (their second field is a name, not a project path). [CmdletBinding()] param([Parameter(Mandatory)][string]$SolutionFile) $sln = Read-Solution -SolutionFile $SolutionFile $entries = @() foreach ($p in $sln.Projects) { if (-not $script:ProjectFileExtRegex.IsMatch($p.Stored)) { continue } $entries += [pscustomobject]@{ Stored = $p.Stored; Abs = $p.Abs } } return $entries } function Get-SolutionContent { # Full contents of one solution (both .sln and .slnx): every project entry (any type, incl. # .pssproj/.vcxproj that `dotnet sln list` may omit), solution folders, and solution items # (loose files). Solution folders are reported separately, never as projects. [CmdletBinding()] param([Parameter(Mandatory)][string]$SolutionFile) $sln = Read-Solution -SolutionFile $SolutionFile $projects = @() foreach ($p in $sln.Projects) { $projects += [pscustomobject]@{ Stored = $p.Stored; Abs = $p.Abs; Ext = $p.Ext } } return [pscustomobject]@{ Projects = $projects; Folders = $sln.Folders; Items = $sln.Items } } function Get-SolutionMembership { # For each solution, the absolute paths of every CLI-buildable project it lists # (.cs/.fs/.vb/.vcxproj), derived from a single parse of each file. [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Solutions) $result = @() foreach ($sln in $Solutions) { $parsed = if ($sln.PSObject.TypeNames -contains 'Netscoot.Solution') { $sln } else { Read-Solution -SolutionFile $sln.FullName } $projects = @() foreach ($p in $parsed.Projects) { if ($script:ProjectFileExtRegex.IsMatch($p.Stored)) { $projects += $p.Abs } } $result += [pscustomobject]@{ Solution = $sln.FullName; Projects = $projects } } return $result } function Get-SolutionsReferencing { # Solutions (from $Candidates) whose project list includes $ProjectFile. [CmdletBinding()] param( [Parameter(Mandatory)][string]$ProjectFile, [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Candidates ) $target = Resolve-FullPath $ProjectFile $hits = @() foreach ($sln in $Candidates) { $parsed = if ($sln.PSObject.TypeNames -contains 'Netscoot.Solution') { $sln } else { Read-Solution -SolutionFile $sln.FullName } foreach ($p in $parsed.Projects) { if (-not $script:ProjectFileExtRegex.IsMatch($p.Stored)) { continue } if (Test-PathEqual $p.Abs $target) { $hits += $sln; break } } } return $hits } function Get-Workspace { # Parse-once domain model for one repository, so a single read/analysis cmdlet builds ONE of # these and derives membership, consuming projects, solutions-referencing, and reference data # from it instead of re-globbing the tree and re-parsing every .sln/.csproj per helper call. # Internal type (Netscoot.Workspace); not a public cmdlet output. # # Solutions are parsed eagerly (cheap, and every read path needs them). The project glob and the # reference index are built LAZILY on first access (Get-WorkspaceProjectFiles / *Refs / # *ConsumingProjects), so a solution-only cmdlet (Test-SolutionConsistency, Sync-Solution) never # pays to glob projects or parse their references, while a cmdlet that needs them pays once. # # Root - resolved repository root. # Solutions - one entry per .sln/.slnx; each is the parsed Netscoot.Solution with .FullName # /.Name added, so it reads like a Find-Solutions result AND the solution helpers # detect the embedded parse to avoid re-reading the file. # Projects - (lazy) one Find-ProjectFiles glob (managed + native). Each entry mirrors the # FileInfo shape (.FullName/.Name/.Extension) plus .Abs (resolved) and .IsManaged. # ProjectRefs - (lazy) resolved project path -> its Get-ProjectReferencePaths (parsed once). # Consumers - (lazy) literal target path -> consumer paths (target->consumers; built with # ProjectRefs in one project parse). [CmdletBinding()] param([Parameter(Mandatory)][string]$RepositoryRoot) $root = Resolve-FullPath $RepositoryRoot # One solution parse each. Wrap so the entry both reads like a Find-Solutions result # (.FullName/.Name) and is itself a Netscoot.Solution (so helpers skip the re-parse). $solutions = @() foreach ($sf in (Find-Solutions -Root $root)) { $parsed = Read-Solution -SolutionFile $sf.FullName $parsed | Add-Member -NotePropertyName FullName -NotePropertyValue $parsed.Path -Force $parsed | Add-Member -NotePropertyName Name -NotePropertyValue (Split-Path -Leaf $parsed.Path) -Force $solutions += $parsed } return [pscustomobject]@{ PSTypeName = 'Netscoot.Workspace' Root = $root Solutions = $solutions Projects = $null # lazy: built by Initialize-WorkspaceProjects on first access ProjectRefs = $null # lazy Consumers = $null # lazy } } function Initialize-WorkspaceProjects { # Build (once, memoized) the workspace's project glob and reference index. Called lazily the # first time any project/reference accessor runs, so solution-only cmdlets never pay for it. [CmdletBinding()] param([Parameter(Mandatory)][object]$Workspace) if ($null -ne $Workspace.Projects) { return } # One project glob (managed + native); tag each so a managed-only caller filters in memory. $projects = @() foreach ($pf in (Find-ProjectFiles -Root $Workspace.Root -IncludeNative)) { $projects += [pscustomobject]@{ FullName = $pf.FullName Name = $pf.Name Extension = $pf.Extension Abs = Resolve-FullPath $pf.FullName IsManaged = ($pf.Extension -in $script:ManagedProjectExtensions) } } # Reference index: parse each project's ProjectReferences exactly once. Index target->consumers # by resolved literal target path (non-literal Includes resolve to no single path, so they index # nothing - matching Get-ConsumingProjects' literal-only contract). $cmp = if (Test-IsWindowsHost) { [System.StringComparer]::OrdinalIgnoreCase } else { [System.StringComparer]::Ordinal } $projectRefs = [System.Collections.Generic.Dictionary[string, object]]::new($cmp) $consumers = [System.Collections.Generic.Dictionary[string, object]]::new($cmp) foreach ($p in $projects) { $refs = @(Get-ProjectReferencePaths -ProjectFile $p.FullName) $projectRefs[$p.Abs] = $refs foreach ($r in $refs) { if (-not $r.IsLiteral) { continue } if (-not $consumers.ContainsKey($r.FullPath)) { $consumers[$r.FullPath] = [System.Collections.Generic.List[string]]::new() } $consumers[$r.FullPath].Add($p.FullName) } } $Workspace.Projects = $projects $Workspace.ProjectRefs = $projectRefs $Workspace.Consumers = $consumers } function Get-WorkspaceSolutions { # The workspace solutions (each reads as a Find-Solutions result via .FullName/.Name and is the # parsed Netscoot.Solution). Streamed; callers wrap with @(...) as they do for Find-Solutions. [CmdletBinding()] param([Parameter(Mandatory)][object]$Workspace) foreach ($s in $Workspace.Solutions) { $s } } function Get-WorkspaceProjectFiles { # The workspace project entries, mirroring Find-ProjectFiles output (.FullName/.Name/.Extension). # Managed-only by default; -IncludeNative also returns .vcxproj. No re-globbing. Streamed, so # callers wrap with @(...) exactly as they do for Find-ProjectFiles. [CmdletBinding()] param([Parameter(Mandatory)][object]$Workspace, [switch]$IncludeNative) Initialize-WorkspaceProjects -Workspace $Workspace foreach ($p in $Workspace.Projects) { if ($IncludeNative -or $p.IsManaged) { $p } } } function Get-WorkspaceConsumingProjects { # Project paths in the workspace with a literal ProjectReference to $ProjectFile, read from the # prebuilt target->consumers index. Same result as Get-ConsumingProjects, with no re-parsing. # Streamed; callers wrap with @(...). [CmdletBinding()] param([Parameter(Mandatory)][object]$Workspace, [Parameter(Mandatory)][string]$ProjectFile) Initialize-WorkspaceProjects -Workspace $Workspace $target = Resolve-FullPath $ProjectFile if (-not $Workspace.Consumers.ContainsKey($target)) { return } # Exclude a self-reference, matching Get-ConsumingProjects (it skips the target itself). foreach ($c in $Workspace.Consumers[$target]) { if (-not (Test-PathEqual (Resolve-FullPath $c) $target)) { $c } } } function Get-WorkspaceProjectRefs { # The parsed ProjectReferences of one project from the workspace cache; falls back to a direct # parse for a project outside the indexed glob. Streamed; callers wrap with @(...). [CmdletBinding()] param([Parameter(Mandatory)][object]$Workspace, [Parameter(Mandatory)][string]$ProjectFile) Initialize-WorkspaceProjects -Workspace $Workspace $abs = Resolve-FullPath $ProjectFile $refs = if ($Workspace.ProjectRefs.ContainsKey($abs)) { $Workspace.ProjectRefs[$abs] } else { @(Get-ProjectReferencePaths -ProjectFile $abs) } foreach ($r in $refs) { $r } } |