Netscoot.Shared/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*"\{[^}]+\}"'
$script:ProjectFileExtRegex = [regex]'\.(cs|fs|vb|vcx)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 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) {
        $slnDir = Split-Path -Parent $sln.FullName
        $listed = Invoke-DotnetRead sln $sln.FullName list
        if ($LASTEXITCODE -ne 0) { continue }
        foreach ($line in $listed) {
            $line = $line.Trim()
            if ([string]::IsNullOrWhiteSpace($line) -or -not $script:ProjectFileExtRegex.IsMatch($line)) { continue }
            $abs = [System.IO.Path]::GetFullPath((Join-Path $slnDir $line))
            if (Test-PathEqual $abs $target) { $hits += $sln; break }
        }
    }
    return $hits
}

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)
    $full = Resolve-FullPath $SolutionFile
    $dir = Split-Path -Parent $full
    $entries = @()
    if ([System.IO.Path]::GetExtension($full) -ieq '.slnx') {
        $xml = Read-ProjectXml -Path $full
        foreach ($n in $xml.SelectNodes('//*[local-name()="Project"]')) {
            $p = $n.GetAttribute('Path')
            if ([string]::IsNullOrWhiteSpace($p) -or -not $script:ProjectFileExtRegex.IsMatch($p)) { continue }
            $abs = [System.IO.Path]::GetFullPath((Join-Path $dir ($p.Replace('/', '\'))))
            $entries += [pscustomobject]@{ Stored = $p; Abs = $abs }
        }
    } else {
        foreach ($line in (Get-Content -LiteralPath $full)) {
            $m = $script:SlnProjectEntryRegex.Match($line)
            if ($m.Success) {
                $p = $m.Groups[1].Value
                if (-not $script:ProjectFileExtRegex.IsMatch($p)) { continue }
                $abs = [System.IO.Path]::GetFullPath((Join-Path $dir $p))
                $entries += [pscustomobject]@{ Stored = $p; Abs = $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)
    $full = Resolve-FullPath $SolutionFile
    $dir = Split-Path -Parent $full
    $projects = @(); $folders = @(); $items = @()
    if ([System.IO.Path]::GetExtension($full) -ieq '.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) }
        }
        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 {
        $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) }
            } 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]@{ Projects = $projects; Folders = $folders; Items = $items }
}

function Get-SolutionMembership {
    # For each solution, the absolute paths of every project it lists.
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Solutions)
    $result = @()
    foreach ($sln in $Solutions) {
        $slnDir = Split-Path -Parent $sln.FullName
        $projects = @()
        $listed = Invoke-DotnetRead sln $sln.FullName list
        if ($LASTEXITCODE -eq 0) {
            foreach ($line in $listed) {
                $line = $line.Trim()
                if ([string]::IsNullOrWhiteSpace($line) -or -not $script:ProjectFileExtRegex.IsMatch($line)) { continue }
                $projects += [System.IO.Path]::GetFullPath((Join-Path $slnDir $line))
            }
        }
        $result += [pscustomobject]@{ Solution = $sln.FullName; Projects = $projects }
    }
    return $result
}